feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
@@ -3,9 +3,14 @@
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Permissions, type AuthContext } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import {
|
||||
getStudentActiveClassId,
|
||||
getStudentActiveGradeId,
|
||||
verifyTeacherOwnsClass,
|
||||
} from "@/modules/classes/data-access"
|
||||
|
||||
import {
|
||||
CreatePracticeSessionSchema,
|
||||
@@ -22,7 +27,98 @@ import {
|
||||
getPracticeSessions,
|
||||
getPracticeStats,
|
||||
} from "./data-access"
|
||||
import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types"
|
||||
import { parseSourceMeta } from "./lib/source-meta"
|
||||
import { practiceErrors } from "./lib/errors"
|
||||
import type {
|
||||
PracticeSessionDetail,
|
||||
PracticeSessionSummary,
|
||||
PracticeStats,
|
||||
} from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限辅助:dataScope 下 studentId 归属校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 根据 dataScope 解析并校验目标学生 ID。
|
||||
*
|
||||
* 行级权限校验(P0 安全修复):
|
||||
* - `all`:admin 可访问任意 studentId(不传则用 ctx.userId)
|
||||
* - `owned`:仅可访问 ctx.userId(学生本人)
|
||||
* - `class_members`:仅可访问 ctx.userId(学生本人)
|
||||
* - `children`:仅可访问 childrenIds 中的学生(家长)
|
||||
* - `class_taught`:校验学生属于教师所教班级(教师)
|
||||
* - `grade_managed`:校验学生属于年级主任所辖年级(年级主任)
|
||||
*
|
||||
* @throws {PermissionDeniedError} studentId 不在 dataScope 范围内
|
||||
* @returns 校验通过的目标 studentId
|
||||
*/
|
||||
async function resolveTargetStudentId(
|
||||
ctx: AuthContext,
|
||||
studentId?: string,
|
||||
): Promise<string> {
|
||||
// 未传 studentId:根据 scope 选取默认值
|
||||
if (!studentId || studentId === ctx.userId) {
|
||||
if (ctx.dataScope.type === "children") {
|
||||
// 家长默认查第一个孩子
|
||||
return ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
return ctx.userId
|
||||
}
|
||||
|
||||
// 传入了 studentId:按 scope 校验归属
|
||||
switch (ctx.dataScope.type) {
|
||||
case "all":
|
||||
// admin 无限制
|
||||
return studentId
|
||||
|
||||
case "owned":
|
||||
// 学生仅能访问自己
|
||||
if (studentId !== ctx.userId) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
|
||||
case "class_members":
|
||||
// 学生仅能访问自己
|
||||
if (studentId !== ctx.userId) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
|
||||
case "children":
|
||||
// 家长仅能访问自己的子女
|
||||
if (!ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
|
||||
case "class_taught": {
|
||||
// 教师:校验学生属于其所教班级
|
||||
const classId = await getStudentActiveClassId(studentId)
|
||||
if (!classId) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
if (!owns) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
}
|
||||
|
||||
case "grade_managed": {
|
||||
// 年级主任:校验学生属于其所辖年级
|
||||
const gradeId = await getStudentActiveGradeId(studentId)
|
||||
if (!gradeId || !ctx.dataScope.gradeIds.includes(gradeId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
}
|
||||
|
||||
default:
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询 Actions
|
||||
@@ -33,25 +129,12 @@ export async function getPracticeSessionsAction(
|
||||
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||
|
||||
const result = await getPracticeSessions(targetStudentId)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "获取练习列表失败"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,28 +144,16 @@ export async function getPracticeSessionDetailAction(
|
||||
): Promise<ActionState<PracticeSessionDetail>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||
|
||||
const data = await getPracticeSessionById(sessionId, targetStudentId)
|
||||
if (!data) {
|
||||
return { success: false, message: "练习会话不存在或无权访问" }
|
||||
// 通过 errorCode 返回,前端 t(`errors.session_not_found`) 查 i18n
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "获取练习详情失败"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,25 +162,12 @@ export async function getPracticeStatsAction(
|
||||
): Promise<ActionState<PracticeStats>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||
|
||||
const data = await getPracticeStats(targetStudentId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "获取练习统计失败"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +184,8 @@ export async function createPracticeSessionAction(
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误,需要 JSON 字段" }
|
||||
// errorCode: invalid_input → 前端 t(`errors.invalid_input`)
|
||||
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
|
||||
}
|
||||
|
||||
const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
|
||||
@@ -134,31 +193,38 @@ export async function createPracticeSessionAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换
|
||||
// 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record<string, unknown>
|
||||
// 而实际运行时结构由前端按练习类型构建,此处做类型收窄
|
||||
const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta
|
||||
// 严格类型守卫:替代 `as unknown as PracticeSourceMeta` 断言
|
||||
const sourceMeta = parseSourceMeta(parsed.data.practiceType, parsed.data.sourceMeta)
|
||||
if (!sourceMeta) {
|
||||
return {
|
||||
success: false,
|
||||
message: "来源元数据结构与练习类型不匹配",
|
||||
errorCode: "invalid_source_meta",
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createPracticeSession(ctx.userId, {
|
||||
// 写入操作仅允许学生本人或家长代子女发起
|
||||
// 家长 dataScope.type === "children" 时,使用第一个子女 ID
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
const result = await createPracticeSession(targetStudentId, {
|
||||
practiceType: parsed.data.practiceType,
|
||||
subjectId: parsed.data.subjectId,
|
||||
sourceMeta,
|
||||
questionCount: parsed.data.questionCount,
|
||||
})
|
||||
|
||||
if (result.selectedCount === 0) {
|
||||
return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" }
|
||||
}
|
||||
|
||||
revalidatePath("/student/practice")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已创建练习会话,共 ${result.selectedCount} 道题目`,
|
||||
data: result,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -175,7 +241,7 @@ export async function submitPracticeAnswerAction(
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误" }
|
||||
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
|
||||
}
|
||||
|
||||
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
|
||||
@@ -183,15 +249,21 @@ export async function submitPracticeAnswerAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionId, answerId, answer, skip } = parsed.data
|
||||
|
||||
// 学生本人或家长代子女提交
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
const result = await submitPracticeAnswer(
|
||||
sessionId,
|
||||
ctx.userId,
|
||||
targetStudentId,
|
||||
answerId,
|
||||
answer,
|
||||
skip ?? false,
|
||||
@@ -201,7 +273,6 @@ export async function submitPracticeAnswerAction(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"),
|
||||
data: result,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -223,16 +294,21 @@ export async function completePracticeSessionAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await completePracticeSession(parsed.data.sessionId, ctx.userId)
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
await completePracticeSession(parsed.data.sessionId, targetStudentId)
|
||||
|
||||
revalidatePath("/student/practice")
|
||||
revalidatePath(`/student/practice/${parsed.data.sessionId}`)
|
||||
|
||||
return { success: true, message: "练习已完成" }
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
@@ -252,15 +328,20 @@ export async function abandonPracticeSessionAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await abandonPracticeSession(parsed.data.sessionId, ctx.userId)
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
await abandonPracticeSession(parsed.data.sessionId, targetStudentId)
|
||||
|
||||
revalidatePath("/student/practice")
|
||||
|
||||
return { success: true, message: "练习已放弃" }
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
|
||||
145
src/modules/adaptive-practice/components/answer-input.tsx
Normal file
145
src/modules/adaptive-practice/components/answer-input.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
|
||||
import { extractOptions } from "../lib/grading"
|
||||
import { isStringArray } from "../lib/answer-utils"
|
||||
|
||||
interface AnswerInputProps {
|
||||
questionType: string
|
||||
content: unknown
|
||||
userAnswer: unknown
|
||||
onAnswerChange: (answer: unknown) => void
|
||||
/** 是否禁用输入 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 答题输入组件。
|
||||
*
|
||||
* 根据题目类型渲染不同的输入控件:
|
||||
* - single_choice: 单选 RadioGroup
|
||||
* - multiple_choice: 多选 Checkbox 组
|
||||
* - judgment: 判断 RadioGroup(正确/错误)
|
||||
* - text/其他: 文本域
|
||||
*
|
||||
* 所有控件均带 aria-label 供屏幕阅读器识别。
|
||||
*/
|
||||
export function AnswerInput({
|
||||
questionType,
|
||||
content,
|
||||
userAnswer,
|
||||
onAnswerChange,
|
||||
disabled = false,
|
||||
}: AnswerInputProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
const options = extractOptions(content)
|
||||
const selectedId = typeof userAnswer === "string" ? userAnswer : ""
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={selectedId}
|
||||
onValueChange={onAnswerChange}
|
||||
disabled={disabled}
|
||||
aria-label={t("session.submit")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={opt.id}
|
||||
id={opt.id}
|
||||
aria-label={opt.text}
|
||||
/>
|
||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "multiple_choice") {
|
||||
const options = extractOptions(content)
|
||||
const selectedIds = isStringArray(userAnswer) ? userAnswer : []
|
||||
|
||||
function toggle(id: string): void {
|
||||
const newIds = selectedIds.includes(id)
|
||||
? selectedIds.filter((v) => v !== id)
|
||||
: [...selectedIds, id]
|
||||
onAnswerChange(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-2"
|
||||
role="group"
|
||||
aria-label={t("session.submit")}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(opt.id)}
|
||||
onCheckedChange={() => toggle(opt.id)}
|
||||
id={opt.id}
|
||||
disabled={disabled}
|
||||
aria-label={opt.text}
|
||||
/>
|
||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const value = typeof userAnswer === "string" ? userAnswer : ""
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={onAnswerChange}
|
||||
disabled={disabled}
|
||||
aria-label={t("session.submit")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="true"
|
||||
id="judgment-true"
|
||||
aria-label={t("session.true")}
|
||||
/>
|
||||
<Label htmlFor="judgment-true">{t("session.true")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="false"
|
||||
id="judgment-false"
|
||||
aria-label={t("session.false")}
|
||||
/>
|
||||
<Label htmlFor="judgment-false">{t("session.false")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// text 题型
|
||||
const textValue = typeof userAnswer === "string" ? userAnswer : ""
|
||||
return (
|
||||
<textarea
|
||||
value={textValue}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
placeholder={t("session.textPlaceholder")}
|
||||
disabled={disabled}
|
||||
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label={t("session.textPlaceholder")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
76
src/modules/adaptive-practice/components/answer-result.tsx
Normal file
76
src/modules/adaptive-practice/components/answer-result.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle2, XCircle } from "lucide-react"
|
||||
|
||||
import { QuestionContent } from "./question-content"
|
||||
import type { PracticeAnswerRecord } from "../types"
|
||||
|
||||
interface AnswerResultProps {
|
||||
answer: PracticeAnswerRecord
|
||||
result?: { isCorrect: boolean | null; score: number | null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 答题结果展示组件。
|
||||
*
|
||||
* 展示:
|
||||
* - 判分结果(正确/错误/待批阅/已跳过)
|
||||
* - 学生答案(使用 QuestionContent 渲染替代 JSON.stringify)
|
||||
*/
|
||||
export function AnswerResult({ answer, result }: AnswerResultProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const isCorrect = result?.isCorrect ?? answer.isCorrect
|
||||
const isSkipped = answer.status === "skipped"
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 判分结果 */}
|
||||
{isSkipped ? (
|
||||
<div
|
||||
className="rounded-md border border-muted bg-muted/30 p-3 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
aria-label={t("session.skipped")}
|
||||
>
|
||||
{t("session.skipped")}
|
||||
</div>
|
||||
) : isCorrect === true ? (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-400"
|
||||
role="status"
|
||||
aria-label={t("session.correct")}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||||
{t("session.correct")}
|
||||
</div>
|
||||
) : isCorrect === false ? (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400"
|
||||
role="status"
|
||||
aria-label={t("session.incorrect")}
|
||||
>
|
||||
<XCircle className="h-5 w-5" aria-hidden="true" />
|
||||
{t("session.incorrect")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-md border border-amber-200 bg-amber-50/50 p-3 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-400"
|
||||
role="status"
|
||||
aria-label={t("session.pendingReview")}
|
||||
>
|
||||
{t("session.pendingReview")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 学生答案 */}
|
||||
{answer.studentAnswer !== null && answer.studentAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-medium">{t("session.yourAnswer")}</h4>
|
||||
<div className="rounded-md border bg-muted/30 p-2 text-xs">
|
||||
<QuestionContent content={answer.studentAnswer} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import type { PracticeSessionSummary, PracticeStatus } from "../types"
|
||||
|
||||
interface PracticeHistoryProps {
|
||||
sessions: PracticeSessionSummary[]
|
||||
/**
|
||||
* 点击某条练习记录跳转的路由前缀。
|
||||
* - 学生端传 `/student/practice`,将跳转到 `/student/practice/{sessionId}`
|
||||
* - 家长端不传(无会话详情页),仅渲染为只读卡片
|
||||
*/
|
||||
routePrefix?: string
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = {
|
||||
@@ -24,7 +30,10 @@ const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline
|
||||
/**
|
||||
* 专项练习历史列表
|
||||
*/
|
||||
export function PracticeHistory({ sessions }: PracticeHistoryProps): React.ReactNode {
|
||||
export function PracticeHistory({
|
||||
sessions,
|
||||
routePrefix,
|
||||
}: PracticeHistoryProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
if (sessions.length === 0) {
|
||||
@@ -48,47 +57,55 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
|
||||
? (session.answeredQuestions / session.totalQuestions) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Link key={session.id} href={`/student/practice/${session.id}`}>
|
||||
<Card className="transition-colors hover:bg-muted/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANTS[session.status]}>
|
||||
{t(`status.${session.status}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t(`types.${session.practiceType}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(session.startedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
{session.correctCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-rose-500" />
|
||||
{session.answeredQuestions - session.correctCount}
|
||||
</span>
|
||||
</div>
|
||||
const content = (
|
||||
<Card className="transition-colors hover:bg-muted/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANTS[session.status]}>
|
||||
{t(`status.${session.status}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t(`types.${session.practiceType}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold">{accuracy}%</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{session.answeredQuestions}/{session.totalQuestions}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(session.startedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
{session.correctCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-rose-500" />
|
||||
{session.answeredQuestions - session.correctCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mt-2 h-1" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold">{accuracy}%</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{session.answeredQuestions}/{session.totalQuestions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mt-2 h-1" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (routePrefix) {
|
||||
return (
|
||||
<Link key={session.id} href={`${routePrefix}/${session.id}`}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <div key={session.id}>{content}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle2, XCircle, Trophy } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
import type { PracticeSessionDetail } from "../types"
|
||||
|
||||
interface PracticeResultViewProps {
|
||||
session: PracticeSessionDetail
|
||||
}
|
||||
|
||||
/**
|
||||
* 练习完成后的结果视图。
|
||||
*
|
||||
* 展示:
|
||||
* - 结果摘要卡片(已答题数、正确数、正确率)
|
||||
* - 逐题回顾列表
|
||||
*
|
||||
* 带有 ARIA role 和 aria-label 供屏幕阅读器识别。
|
||||
*/
|
||||
export function PracticeResultView({ session }: PracticeResultViewProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
const total = session.totalQuestions
|
||||
const answered = session.answeredQuestions
|
||||
const correct = session.correctCount
|
||||
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("result.title")}
|
||||
>
|
||||
<Trophy className="h-5 w-5 text-amber-500" aria-hidden="true" />
|
||||
{t("result.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 结果摘要 */}
|
||||
<div
|
||||
className="grid grid-cols-3 gap-4"
|
||||
role="region"
|
||||
aria-label={t("result.title")}
|
||||
>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold" aria-label={`${t("result.answered")}: ${answered}`}>
|
||||
{answered}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.answered")}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div
|
||||
className="text-2xl font-bold text-emerald-600"
|
||||
aria-label={`${t("result.correct")}: ${correct}`}
|
||||
>
|
||||
{correct}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.correct")}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div
|
||||
className="text-2xl font-bold"
|
||||
aria-label={`${t("result.accuracy")}: ${accuracy}%`}
|
||||
>
|
||||
{accuracy}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.accuracy")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 逐题回顾 */}
|
||||
<div className="space-y-2" role="list" aria-label={t("result.review")}>
|
||||
<h4 className="text-sm font-medium">{t("result.review")}</h4>
|
||||
{session.answers.map((a, idx) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
||||
role="listitem"
|
||||
aria-label={`${t("session.question")} ${idx + 1}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{a.isCorrect === true ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
|
||||
) : a.isCorrect === false ? (
|
||||
<XCircle className="h-4 w-4 text-rose-500" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground" aria-hidden="true">—</span>
|
||||
)}
|
||||
{t("session.question")} {idx + 1}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{a.status === "answered"
|
||||
? (a.isCorrect === true
|
||||
? t("session.correct")
|
||||
: a.isCorrect === false
|
||||
? t("session.incorrect")
|
||||
: t("session.pendingReview"))
|
||||
: t("session.skipped")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -3,16 +3,12 @@
|
||||
import { useState, useTransition, useMemo } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle2, XCircle, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -25,36 +21,55 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
|
||||
import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions"
|
||||
import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types"
|
||||
import { QuestionCard } from "./question-card"
|
||||
import { PracticeResultView } from "./practice-result-view"
|
||||
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
|
||||
import type { PracticeSessionDetail } from "../types"
|
||||
|
||||
interface PracticeSessionViewProps {
|
||||
session: PracticeSessionDetail
|
||||
/**
|
||||
* 放弃练习后的跳转回调(由页面层注入路由跳转逻辑)。
|
||||
*
|
||||
* 解耦设计:组件本身不硬编码路由,由调用方决定跳转目标。
|
||||
* 不传则使用默认行为 `router.push("/student/practice")`(向后兼容)。
|
||||
*/
|
||||
onAbandoned?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 专项练习答题界面
|
||||
* 专项练习答题界面(编排层)。
|
||||
*
|
||||
* 功能:
|
||||
* 1. 逐题作答,支持上一题/下一题导航
|
||||
* 2. 自动判分(选择题/判断题)
|
||||
* 3. 跳过题目
|
||||
* 4. 完成练习后展示结果摘要
|
||||
* 职责:
|
||||
* 1. 管理 UI 状态(当前题目索引、答案暂存、判分结果缓存)
|
||||
* 2. 通过 `usePracticeService()` 调用数据服务(不直接 import actions)
|
||||
* 3. 组合 QuestionCard / PracticeResultView 等子组件
|
||||
*
|
||||
* 子组件:
|
||||
* - QuestionCard: 题目卡片(含内容渲染、答题输入、结果展示)
|
||||
* - PracticeResultView: 完成后的结果视图
|
||||
*
|
||||
* 提交失败支持重试(通过 submitFailed + onRetry 传递给 QuestionCard)。
|
||||
*/
|
||||
export function PracticeSessionView({ session }: PracticeSessionViewProps): React.ReactNode {
|
||||
export function PracticeSessionView({
|
||||
session,
|
||||
onAbandoned,
|
||||
}: PracticeSessionViewProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const router = useRouter()
|
||||
const service = usePracticeService()
|
||||
const analytics = usePracticeAnalytics()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<string, unknown>>({})
|
||||
const [results, setResults] = useState<Record<string, { isCorrect: boolean | null; score: number | null }>>({})
|
||||
const [failedAnswerIds, setFailedAnswerIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const answersList = session.answers
|
||||
const total = answersList.length
|
||||
const current = answersList[currentIndex]
|
||||
const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0
|
||||
|
||||
// 已答题数和正确数
|
||||
const answeredCount = useMemo(
|
||||
() => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length,
|
||||
[answersList],
|
||||
@@ -64,6 +79,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
[answersList],
|
||||
)
|
||||
|
||||
function resolveErrorMessage(
|
||||
errorCode: string | undefined,
|
||||
message: string | undefined,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
if (errorCode) {
|
||||
return t(`errors.${errorCode}`)
|
||||
}
|
||||
return message ?? t(fallbackKey)
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
return <PracticeResultView session={session} />
|
||||
}
|
||||
@@ -92,16 +118,25 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
skip,
|
||||
}),
|
||||
)
|
||||
const res = await submitPracticeAnswerAction(undefined, formData)
|
||||
const res = await service.submitAnswer(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
setResults((prev) => ({ ...prev, [current.id]: res.data! }))
|
||||
const data = res.data
|
||||
setResults((prev) => ({ ...prev, [current.id]: data }))
|
||||
setFailedAnswerIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(current.id)
|
||||
return next
|
||||
})
|
||||
analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect)
|
||||
toast.success(res.message ?? t("toasts.submitted"))
|
||||
// 自动跳到下一题
|
||||
if (currentIndex < total - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.submitFailed"))
|
||||
// 标记提交失败,显示重试按钮
|
||||
setFailedAnswerIds((prev) => new Set(prev).add(current.id))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -110,12 +145,13 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("sessionId", session.id)
|
||||
const res = await completePracticeSessionAction(undefined, formData)
|
||||
const res = await service.completeSession(undefined, formData)
|
||||
if (res.success) {
|
||||
analytics.trackSessionComplete(session.id, session.accuracy)
|
||||
toast.success(res.message ?? t("toasts.completed"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.completeFailed"))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -124,18 +160,32 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("sessionId", session.id)
|
||||
const res = await abandonPracticeSessionAction(undefined, formData)
|
||||
const res = await service.abandonSession(undefined, formData)
|
||||
if (res.success) {
|
||||
analytics.trackSessionAbandon(session.id)
|
||||
toast.success(res.message ?? t("toasts.abandoned"))
|
||||
router.push("/student/practice")
|
||||
// 优先使用注入的回调,否则回退到默认路由
|
||||
if (onAbandoned) {
|
||||
onAbandoned()
|
||||
} else {
|
||||
router.push("/student/practice")
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.abandonFailed"))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRetry(): void {
|
||||
if (current) {
|
||||
analytics.trackErrorRetry(current.id)
|
||||
}
|
||||
handleSubmit(answers[current?.id ?? ""] ?? null)
|
||||
}
|
||||
|
||||
const currentResult = current ? results[current.id] : undefined
|
||||
const isAnswered = current?.status === "answered" || current?.status === "skipped"
|
||||
const submitFailed = current ? failedAnswerIds.has(current.id) : false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -143,18 +193,27 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
<CardTitle
|
||||
className="text-lg"
|
||||
aria-label={`${t("session.progress")}: ${currentIndex + 1} / ${total}`}
|
||||
>
|
||||
{t("session.progress")}: {currentIndex + 1} / {total}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="flex items-center gap-1" aria-label={`${t("result.correct")}: ${correctCount}`}>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
|
||||
{correctCount}
|
||||
</span>
|
||||
<span>{answeredCount}/{total}</span>
|
||||
<span aria-label={`${t("result.answered")}: ${answeredCount}/${total}`}>
|
||||
{answeredCount}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mt-2" />
|
||||
<Progress
|
||||
value={progress}
|
||||
className="mt-2"
|
||||
aria-label={`${t("session.progress")}: ${Math.round(progress)}%`}
|
||||
/>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -168,9 +227,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
|
||||
onSubmit={(ans) => handleSubmit(ans)}
|
||||
onSkip={() => handleSubmit(null, true)}
|
||||
onRetry={handleRetry}
|
||||
isAnswered={isAnswered}
|
||||
result={currentResult}
|
||||
isPending={isPending}
|
||||
submitFailed={submitFailed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -180,16 +241,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
variant="outline"
|
||||
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
|
||||
disabled={currentIndex === 0}
|
||||
aria-label={t("session.previous")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
{t("session.previous")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Flag className="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" aria-label={t("session.abandon")}>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
{t("session.abandon")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
@@ -200,7 +262,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleAbandon} disabled={isPending}>
|
||||
<AlertDialogAction
|
||||
onClick={handleAbandon}
|
||||
disabled={isPending}
|
||||
aria-label={t("session.confirmAbandon")}
|
||||
>
|
||||
{t("session.confirmAbandon")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -211,13 +277,18 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentIndex(currentIndex + 1)}
|
||||
aria-label={t("session.next")}
|
||||
>
|
||||
{t("session.next")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleComplete} disabled={isPending || answeredCount < total}>
|
||||
<Trophy className="h-4 w-4" />
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={isPending || answeredCount < total}
|
||||
aria-label={t("session.complete")}
|
||||
>
|
||||
<Trophy className="h-4 w-4" aria-hidden="true" />
|
||||
{t("session.complete")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -226,325 +297,3 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 题目卡片
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QuestionCardProps {
|
||||
answer: PracticeAnswerRecord
|
||||
index: number
|
||||
total: number
|
||||
userAnswer: unknown
|
||||
onAnswerChange: (answer: unknown) => void
|
||||
onSubmit: (answer: unknown) => void
|
||||
onSkip: () => void
|
||||
isAnswered: boolean
|
||||
result?: { isCorrect: boolean | null; score: number | null }
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
function QuestionCard({
|
||||
answer,
|
||||
index,
|
||||
total,
|
||||
userAnswer,
|
||||
onAnswerChange,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
isAnswered,
|
||||
result,
|
||||
isPending,
|
||||
}: QuestionCardProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
const question = answer.question
|
||||
const content = answer.variantContent ?? question?.content
|
||||
const questionType = question?.type ?? "unknown"
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{t("session.question")} {index + 1}/{total}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{questionType}</Badge>
|
||||
{question?.difficulty ? (
|
||||
<Badge variant="secondary">
|
||||
{t("session.difficulty")}: {question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
{answer.isVariant ? (
|
||||
<Badge variant="default">{t("session.variant")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 题目内容 */}
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{typeof content === "string"
|
||||
? content
|
||||
: JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 作答区域 */}
|
||||
{!isAnswered ? (
|
||||
<AnswerInput
|
||||
questionType={questionType}
|
||||
content={content}
|
||||
userAnswer={userAnswer}
|
||||
onAnswerChange={onAnswerChange}
|
||||
/>
|
||||
) : (
|
||||
<AnswerResult
|
||||
answer={answer}
|
||||
result={result}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{!isAnswered ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onSkip} disabled={isPending}>
|
||||
{t("session.skip")}
|
||||
</Button>
|
||||
<Button onClick={() => onSubmit(userAnswer)} disabled={isPending || userAnswer === undefined}>
|
||||
{isPending ? t("session.submitting") : t("session.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 答题输入组件
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnswerInputProps {
|
||||
questionType: string
|
||||
content: unknown
|
||||
userAnswer: unknown
|
||||
onAnswerChange: (answer: unknown) => void
|
||||
}
|
||||
|
||||
function AnswerInput({ questionType, content, userAnswer, onAnswerChange }: AnswerInputProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
const options = extractOptions(content)
|
||||
const selectedId = typeof userAnswer === "string" ? userAnswer : ""
|
||||
|
||||
return (
|
||||
<RadioGroup value={selectedId} onValueChange={onAnswerChange}>
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt.id} id={opt.id} />
|
||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "multiple_choice") {
|
||||
const options = extractOptions(content)
|
||||
const selectedIds = Array.isArray(userAnswer) ? userAnswer as string[] : []
|
||||
|
||||
function toggle(id: string): void {
|
||||
const newIds = selectedIds.includes(id)
|
||||
? selectedIds.filter((v) => v !== id)
|
||||
: [...selectedIds, id]
|
||||
onAnswerChange(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(opt.id)}
|
||||
onCheckedChange={() => toggle(opt.id)}
|
||||
id={opt.id}
|
||||
/>
|
||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const value = typeof userAnswer === "string" ? userAnswer : ""
|
||||
|
||||
return (
|
||||
<RadioGroup value={value} onValueChange={onAnswerChange}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="true" id="judgment-true" />
|
||||
<Label htmlFor="judgment-true">{t("session.true")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="false" id="judgment-false" />
|
||||
<Label htmlFor="judgment-false">{t("session.false")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// text 题型
|
||||
return (
|
||||
<textarea
|
||||
value={typeof userAnswer === "string" ? userAnswer : ""}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
placeholder={t("session.textPlaceholder")}
|
||||
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 答题结果展示
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnswerResultProps {
|
||||
answer: PracticeAnswerRecord
|
||||
result?: { isCorrect: boolean | null; score: number | null }
|
||||
}
|
||||
|
||||
function AnswerResult({ answer, result }: AnswerResultProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const isCorrect = result?.isCorrect ?? answer.isCorrect
|
||||
const isSkipped = answer.status === "skipped"
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 判分结果 */}
|
||||
{isSkipped ? (
|
||||
<div className="rounded-md border border-muted bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
{t("session.skipped")}
|
||||
</div>
|
||||
) : isCorrect === true ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
{t("session.correct")}
|
||||
</div>
|
||||
) : isCorrect === false ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400">
|
||||
<XCircle className="h-5 w-5" />
|
||||
{t("session.incorrect")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-400">
|
||||
{t("session.pendingReview")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 学生答案 */}
|
||||
{answer.studentAnswer !== null && answer.studentAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-medium">{t("session.yourAnswer")}</h4>
|
||||
<pre className="whitespace-pre-wrap break-words rounded-md border bg-muted/30 p-2 text-xs font-sans">
|
||||
{typeof answer.studentAnswer === "string"
|
||||
? answer.studentAnswer
|
||||
: JSON.stringify(answer.studentAnswer, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 练习结果视图
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PracticeResultView({ session }: { session: PracticeSessionDetail }): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
const total = session.totalQuestions
|
||||
const answered = session.answeredQuestions
|
||||
const correct = session.correctCount
|
||||
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{t("result.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold">{answered}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.answered")}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">{correct}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.correct")}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold">{accuracy}%</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.accuracy")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 逐题回顾 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("result.review")}</h4>
|
||||
{session.answers.map((a, idx) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{a.isCorrect === true ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : a.isCorrect === false ? (
|
||||
<XCircle className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{t("session.question")} {idx + 1}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{a.status === "answered" ? (a.isCorrect === true ? t("session.correct") : a.isCorrect === false ? t("session.incorrect") : t("session.pendingReview")) : t("session.skipped")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractOptions(content: unknown): Array<{ id: string; text: string }> {
|
||||
if (typeof content !== "object" || content === null) return []
|
||||
const record = content as Record<string, unknown>
|
||||
const options = record.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt): opt is Record<string, unknown> =>
|
||||
typeof opt === "object" && opt !== null && typeof opt.id === "string",
|
||||
)
|
||||
.map((opt) => ({
|
||||
id: opt.id as string,
|
||||
text: typeof opt.text === "string" ? opt.text : String(opt.text ?? ""),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { PracticeStarter, type PracticeStarterProps } from "./practice-starter"
|
||||
|
||||
type PracticeStarterWithNavProps = Omit<PracticeStarterProps, "onSessionCreated"> & {
|
||||
/**
|
||||
* 会话创建成功后跳转的路径前缀,sessionId 会被自动拼接。
|
||||
*
|
||||
* 例如:`/student/practice` → 跳转到 `/student/practice/{sessionId}`。
|
||||
* 不同角色可传不同前缀:学生 `/student/practice`、家长 `/parent/practice`。
|
||||
*/
|
||||
routePrefix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PracticeStarter 的路由导航包装器。
|
||||
*
|
||||
* 职责:将 `onSessionCreated` 回调与 `useRouter` 路由跳转绑定。
|
||||
*
|
||||
* 解耦设计:
|
||||
* - `PracticeStarter` 本身不感知路由结构,仅暴露 `onSessionCreated` 回调
|
||||
* - 此包装器在 Client Component 中使用 `useRouter`,由页面层渲染
|
||||
* - Server Component 页面可直接渲染此包装器(无需额外客户端中间层)
|
||||
*
|
||||
* @example
|
||||
* // app/(dashboard)/student/practice/page.tsx (Server Component)
|
||||
* <PracticeStarterWithNav
|
||||
* knowledgePoints={knowledgePoints}
|
||||
* routePrefix="/student/practice"
|
||||
* />
|
||||
*/
|
||||
export function PracticeStarterWithNav({
|
||||
routePrefix,
|
||||
...rest
|
||||
}: PracticeStarterWithNavProps): React.ReactNode {
|
||||
const router = useRouter()
|
||||
|
||||
function handleSessionCreated(sessionId: string): void {
|
||||
router.push(`${routePrefix}/${sessionId}`)
|
||||
}
|
||||
|
||||
return <PracticeStarter {...rest} onSessionCreated={handleSessionCreated} />
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createPracticeSessionAction } from "../actions"
|
||||
import type { PracticeType } from "../types"
|
||||
import { isPracticeType } from "../lib/type-guards"
|
||||
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
|
||||
import type { AiRecommendedReason, PracticeType } from "../types"
|
||||
|
||||
interface PracticeStarterProps {
|
||||
export interface PracticeStarterProps {
|
||||
/** 知识点选项列表 */
|
||||
knowledgePoints: Array<{ id: string; name: string }>
|
||||
/** 预设模式(从错题本发起时传入) */
|
||||
@@ -31,22 +32,50 @@ interface PracticeStarterProps {
|
||||
}
|
||||
/** 是否禁用类型选择(预设模式时) */
|
||||
lockType?: boolean
|
||||
/**
|
||||
* 会话创建成功后的回调(由页面层注入路由跳转逻辑)。
|
||||
*
|
||||
* 解耦设计:组件本身不感知路由,由调用方决定跳转目标
|
||||
* (学生跳 `/student/practice/[id]`,家长可能跳 `/parent/practice/[id]`)。
|
||||
* 不传则不跳转(仅 toast 提示)。
|
||||
*/
|
||||
onSessionCreated?: (sessionId: string) => void
|
||||
/**
|
||||
* AI 推荐练习的发起角色(决定 sourceMeta.reason 枚举值)。
|
||||
*
|
||||
* - 学生自主发起(默认):`"student_initiated"`
|
||||
* - 家长建议子女:`"parent_suggested"`
|
||||
* - 教师布置:`"teacher_assigned"`
|
||||
*/
|
||||
aiRecommendReason?: AiRecommendedReason
|
||||
}
|
||||
|
||||
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
|
||||
|
||||
/**
|
||||
* 专项练习发起器
|
||||
* 专项练习发起器。
|
||||
*
|
||||
* 通过 `usePracticeService()` 获取数据服务,不直接 import actions,
|
||||
* 实现完全解耦:测试时可注入 mock 服务,不同角色可注入不同实现。
|
||||
*
|
||||
* 路由跳转通过 `onSessionCreated` 回调注入,组件本身不感知路由结构。
|
||||
*
|
||||
* 支持四种练习模式:
|
||||
* 1. 错题变式练习:从错题本发起
|
||||
* 1. 错题重做:从错题本发起(通过 presetMode)
|
||||
* 2. 知识点专项:选择知识点后抽题
|
||||
* 3. 薄弱章节:自动识别薄弱知识点
|
||||
* 4. AI 推荐:AI 根据学情推荐
|
||||
* 3. 薄弱章节:自动识别薄弱知识点(不传 chapterId 时跨章节识别)
|
||||
* 4. AI 推荐:根据学情推荐
|
||||
*/
|
||||
export function PracticeStarter({ knowledgePoints, presetMode, lockType }: PracticeStarterProps): React.ReactNode {
|
||||
export function PracticeStarter({
|
||||
knowledgePoints,
|
||||
presetMode,
|
||||
lockType,
|
||||
onSessionCreated,
|
||||
aiRecommendReason = "student_initiated",
|
||||
}: PracticeStarterProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const router = useRouter()
|
||||
const service = usePracticeService()
|
||||
const analytics = usePracticeAnalytics()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const [practiceType, setPracticeType] = useState<PracticeType>(
|
||||
@@ -62,6 +91,38 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
)
|
||||
}
|
||||
|
||||
function handleTypeChange(value: string): void {
|
||||
// 严格类型守卫替代 `value as PracticeType` 断言
|
||||
if (isPracticeType(value)) {
|
||||
setPracticeType(value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDifficultyChange(value: string): void {
|
||||
const num = Number(value)
|
||||
if (Number.isInteger(num)) {
|
||||
setDifficulty(num)
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuestionCountChange(value: string): void {
|
||||
const num = Number(value)
|
||||
if (Number.isInteger(num)) {
|
||||
setQuestionCount(num)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveErrorMessage(
|
||||
errorCode: string | undefined,
|
||||
message: string | undefined,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
if (errorCode) {
|
||||
return t(`errors.${errorCode}`)
|
||||
}
|
||||
return message ?? t(fallbackKey)
|
||||
}
|
||||
|
||||
function handleStart(): void {
|
||||
if (presetMode) {
|
||||
// 预设模式:直接使用传入的 sourceMeta
|
||||
@@ -76,73 +137,80 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
questionCount,
|
||||
}),
|
||||
)
|
||||
const res = await createPracticeSessionAction(undefined, formData)
|
||||
const res = await service.createSession(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
analytics.trackSessionStart(presetMode.type, questionCount)
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
router.push(`/student/practice/${res.data.sessionId}`)
|
||||
onSessionCreated?.(res.data.sessionId)
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.createFailed"))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 自定义模式:根据类型构建 sourceMeta
|
||||
let sourceMeta: Record<string, unknown> = {}
|
||||
|
||||
if (practiceType === "knowledge_point") {
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
knowledgePointIds: selectedKpIds,
|
||||
difficulty: difficulty > 0 ? difficulty : undefined,
|
||||
}
|
||||
} else if (practiceType === "weak_chapter") {
|
||||
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
|
||||
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectWeakKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
weakKnowledgePointIds: selectedKpIds,
|
||||
}
|
||||
} else if (practiceType === "ai_recommended") {
|
||||
sourceMeta = {
|
||||
recommendedKnowledgePointIds: selectedKpIds,
|
||||
// 枚举值(业务数据),UI 层通过 t(`reasons.${reason}`) 查 i18n
|
||||
reason: aiRecommendReason,
|
||||
}
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
"json",
|
||||
JSON.stringify({
|
||||
practiceType,
|
||||
sourceMeta,
|
||||
questionCount,
|
||||
}),
|
||||
)
|
||||
const res = await service.createSession(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
analytics.trackSessionStart(practiceType, questionCount)
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
onSessionCreated?.(res.data.sessionId)
|
||||
} else {
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 自定义模式:根据类型构建 sourceMeta
|
||||
let sourceMeta: Record<string, unknown> = {}
|
||||
|
||||
if (practiceType === "knowledge_point") {
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
knowledgePointIds: selectedKpIds,
|
||||
difficulty: difficulty > 0 ? difficulty : undefined,
|
||||
}
|
||||
} else if (practiceType === "weak_chapter") {
|
||||
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectWeakKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
chapterId: "",
|
||||
weakKnowledgePointIds: selectedKpIds,
|
||||
}
|
||||
} else if (practiceType === "ai_recommended") {
|
||||
sourceMeta = {
|
||||
recommendedKnowledgePointIds: selectedKpIds,
|
||||
reason: t("toasts.aiRecommendedReason"),
|
||||
}
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
"json",
|
||||
JSON.stringify({
|
||||
practiceType,
|
||||
sourceMeta,
|
||||
questionCount,
|
||||
}),
|
||||
)
|
||||
const res = await createPracticeSessionAction(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
router.push(`/student/practice/${res.data.sessionId}`)
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showKpSelector = !presetMode || practiceType === "knowledge_point" || practiceType === "weak_chapter" || practiceType === "ai_recommended"
|
||||
const showKpSelector =
|
||||
!presetMode ||
|
||||
practiceType === "knowledge_point" ||
|
||||
practiceType === "weak_chapter" ||
|
||||
practiceType === "ai_recommended"
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
<Target className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
{t("starter.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("starter.description")}</CardDescription>
|
||||
@@ -151,30 +219,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
{/* 练习类型选择 */}
|
||||
{!lockType ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.type")}</Label>
|
||||
<Select
|
||||
value={practiceType}
|
||||
onValueChange={(v: string) => setPracticeType(v as PracticeType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<Label htmlFor="practice-type-select">{t("starter.type")}</Label>
|
||||
<Select value={practiceType} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger id="practice-type-select" aria-label={t("starter.type")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="knowledge_point">
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
{t("types.knowledge_point")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="weak_chapter">
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{t("types.weak_chapter")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="ai_recommended">
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{t("types.ai_recommended")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
@@ -187,21 +252,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
{showKpSelector && knowledgePoints.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.knowledgePoints")}</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2">
|
||||
{knowledgePoints.map((kp) => (
|
||||
<label
|
||||
key={kp.id}
|
||||
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKpIds.includes(kp.id)}
|
||||
onChange={() => toggleKp(kp.id)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-sm">{kp.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2"
|
||||
role="group"
|
||||
aria-label={t("starter.knowledgePoints")}
|
||||
>
|
||||
{knowledgePoints.map((kp) => {
|
||||
const checked = selectedKpIds.includes(kp.id)
|
||||
return (
|
||||
<label
|
||||
key={kp.id}
|
||||
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleKp(kp.id)}
|
||||
aria-label={kp.name}
|
||||
/>
|
||||
<span className="text-sm">{kp.name}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -209,12 +280,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
{/* 难度选择(仅知识点专项) */}
|
||||
{practiceType === "knowledge_point" && !presetMode ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.difficulty")}</Label>
|
||||
<Select
|
||||
value={String(difficulty)}
|
||||
onValueChange={(v: string) => setDifficulty(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<Label htmlFor="difficulty-select">{t("starter.difficulty")}</Label>
|
||||
<Select value={String(difficulty)} onValueChange={handleDifficultyChange}>
|
||||
<SelectTrigger id="difficulty-select" aria-label={t("starter.difficulty")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -231,12 +299,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
|
||||
{/* 题目数量 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.questionCount")}</Label>
|
||||
<Select
|
||||
value={String(questionCount)}
|
||||
onValueChange={(v: string) => setQuestionCount(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<Label htmlFor="question-count-select">{t("starter.questionCount")}</Label>
|
||||
<Select value={String(questionCount)} onValueChange={handleQuestionCountChange}>
|
||||
<SelectTrigger id="question-count-select" aria-label={t("starter.questionCount")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -254,6 +319,7 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
onClick={handleStart}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
aria-label={isPending ? t("starter.creating") : t("starter.start")}
|
||||
>
|
||||
{isPending ? t("starter.creating") : t("starter.start")}
|
||||
</Button>
|
||||
|
||||
149
src/modules/adaptive-practice/components/question-card.tsx
Normal file
149
src/modules/adaptive-practice/components/question-card.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
import { QuestionContent } from "./question-content"
|
||||
import { AnswerInput } from "./answer-input"
|
||||
import { AnswerResult } from "./answer-result"
|
||||
import type { PracticeAnswerRecord } from "../types"
|
||||
|
||||
interface QuestionCardProps {
|
||||
answer: PracticeAnswerRecord
|
||||
index: number
|
||||
total: number
|
||||
userAnswer: unknown
|
||||
onAnswerChange: (answer: unknown) => void
|
||||
onSubmit: (answer: unknown) => void
|
||||
onSkip: () => void
|
||||
onRetry?: () => void
|
||||
isAnswered: boolean
|
||||
result?: { isCorrect: boolean | null; score: number | null }
|
||||
isPending: boolean
|
||||
/** 提交是否失败(用于显示重试按钮) */
|
||||
submitFailed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目卡片组件。
|
||||
*
|
||||
* 展示单道题目及其作答/结果区域,负责:
|
||||
* - 题目元信息(类型、难度、变式标记)
|
||||
* - 题目内容(使用 QuestionContent 替代 JSON.stringify)
|
||||
* - 作答输入(未作答时)或结果展示(已作答时)
|
||||
* - 操作按钮(提交、跳过、重试)
|
||||
*/
|
||||
export function QuestionCard({
|
||||
answer,
|
||||
index,
|
||||
total,
|
||||
userAnswer,
|
||||
onAnswerChange,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
onRetry,
|
||||
isAnswered,
|
||||
result,
|
||||
isPending,
|
||||
submitFailed = false,
|
||||
}: QuestionCardProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
const question = answer.question
|
||||
const content = answer.variantContent ?? question?.content
|
||||
const questionType = question?.type ?? "unknown"
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{t("session.question")} {index + 1}/{total}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" aria-label={`题型: ${questionType}`}>
|
||||
{questionType}
|
||||
</Badge>
|
||||
{question?.difficulty ? (
|
||||
<Badge variant="secondary" aria-label={`难度: ${question.difficulty}`}>
|
||||
{t("session.difficulty")}: {question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
{answer.isVariant ? (
|
||||
<Badge variant="default" aria-label={t("session.variant")}>
|
||||
{t("session.variant")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 题目内容 */}
|
||||
<div
|
||||
className="rounded-md border bg-muted/30 p-4 text-sm"
|
||||
role="region"
|
||||
aria-label={`题目 ${index + 1}`}
|
||||
>
|
||||
<QuestionContent content={content} />
|
||||
</div>
|
||||
|
||||
{/* 作答区域 */}
|
||||
{!isAnswered ? (
|
||||
<AnswerInput
|
||||
questionType={questionType}
|
||||
content={content}
|
||||
userAnswer={userAnswer}
|
||||
onAnswerChange={onAnswerChange}
|
||||
disabled={isPending}
|
||||
/>
|
||||
) : (
|
||||
<AnswerResult answer={answer} result={result} />
|
||||
)}
|
||||
|
||||
{/* 提交失败提示 + 重试 */}
|
||||
{submitFailed && !isAnswered ? (
|
||||
<div
|
||||
className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{t("session.submitFailedDescription")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{!isAnswered ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
disabled={isPending}
|
||||
aria-label={t("session.skip")}
|
||||
>
|
||||
{t("session.skip")}
|
||||
</Button>
|
||||
{submitFailed && onRetry ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={isPending}
|
||||
aria-label={t("session.retry")}
|
||||
>
|
||||
{t("session.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => onSubmit(userAnswer)}
|
||||
disabled={isPending || userAnswer === undefined}
|
||||
aria-label={t("session.submit")}
|
||||
>
|
||||
{isPending ? t("session.submitting") : t("session.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { isRecord } from "@/shared/lib/type-guards"
|
||||
import { extractOptions } from "../lib/grading"
|
||||
|
||||
interface QuestionContentProps {
|
||||
/** 题目内容(字符串或结构化对象) */
|
||||
content: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目内容渲染器。
|
||||
*
|
||||
* 替代原先的 JSON.stringify 兜底渲染,根据内容结构智能展示:
|
||||
* - 字符串:直接渲染文本
|
||||
* - 对象含 question/stem/prompt 字段:提取题干文本
|
||||
* - 对象含 options 字段:渲染选项列表(仅展示,不含交互)
|
||||
* - 其他:降级为格式化 JSON(带 aria-label 供屏幕阅读器)
|
||||
*/
|
||||
export function QuestionContent({ content }: QuestionContentProps): React.ReactNode {
|
||||
// 1. 字符串:直接渲染
|
||||
if (typeof content === "string") {
|
||||
return (
|
||||
<pre
|
||||
className="whitespace-pre-wrap break-words font-sans"
|
||||
aria-label="题目内容"
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 结构化对象:尝试提取题干
|
||||
if (isRecord(content)) {
|
||||
const stem = content.question ?? content.stem ?? content.prompt ?? content.text
|
||||
const options = extractOptions(content)
|
||||
|
||||
if (typeof stem === "string" || typeof stem === "number") {
|
||||
return (
|
||||
<div className="space-y-3" aria-label="题目内容">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{String(stem)}
|
||||
</pre>
|
||||
{options.length > 0 ? (
|
||||
<ol className="space-y-1 text-sm text-muted-foreground">
|
||||
{options.map((opt, idx) => (
|
||||
<li key={opt.id} className="flex gap-2">
|
||||
<span className="font-medium">{String.fromCharCode(65 + idx)}.</span>
|
||||
<span>{opt.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 降级:格式化 JSON(带语义化标签)
|
||||
return (
|
||||
<pre
|
||||
className="whitespace-pre-wrap break-words font-mono text-xs"
|
||||
aria-label="题目原始内容"
|
||||
role="region"
|
||||
>
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { practiceAnswers, practiceSessions, questions, questionsToKnowledgePoints, knowledgePoints } from "@/shared/db/schema"
|
||||
import { getActiveStudentIdsByClassId, getClassNameById, getClassesByGradeId } from "@/modules/classes/data-access"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassNamesByIds,
|
||||
getClassNameById,
|
||||
getClassesByGradeId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,6 +59,8 @@ export interface PracticeTypeBreakdown {
|
||||
* 获取班级专项练习统计。
|
||||
*
|
||||
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
|
||||
* 使用单条 SQL 同时计算汇总统计与活跃学生数(COUNT(DISTINCT)),
|
||||
* 避免 N+1 查询。
|
||||
*
|
||||
* @param classId 班级 ID
|
||||
*/
|
||||
@@ -63,12 +70,14 @@ export const getClassPracticeStats = cache(async (
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) return null
|
||||
|
||||
// 单条 SQL 同时获取汇总统计与活跃学生数(替代原先两条 SQL)
|
||||
const rows = await db
|
||||
.select({
|
||||
totalSessions: count(),
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
@@ -78,14 +87,7 @@ export const getClassPracticeStats = cache(async (
|
||||
|
||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||
const totalCorrect = Number(row.totalCorrect)
|
||||
|
||||
// 统计参与练习的学生数
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
||||
const activeStudents = Number(row.activeStudents)
|
||||
|
||||
return {
|
||||
classId,
|
||||
@@ -141,6 +143,9 @@ export const getClassStudentPracticeSummaries = cache(async (
|
||||
/**
|
||||
* 获取年级专项练习统计。
|
||||
*
|
||||
* 使用单条 SQL 同时计算汇总统计与活跃学生数(COUNT(DISTINCT)),
|
||||
* 避免 N+1 查询。
|
||||
*
|
||||
* @param gradeId 年级 ID
|
||||
*/
|
||||
export const getGradePracticeStats = cache(async (
|
||||
@@ -162,6 +167,7 @@ export const getGradePracticeStats = cache(async (
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
@@ -172,18 +178,13 @@ export const getGradePracticeStats = cache(async (
|
||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||
const totalCorrect = Number(row.totalCorrect)
|
||||
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
return {
|
||||
totalSessions: Number(row.totalSessions),
|
||||
completedSessions: Number(row.completedSessions),
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents: Number(activeStudentsResult[0]?.count ?? 0),
|
||||
activeStudents: Number(row.activeStudents),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -271,6 +272,10 @@ export interface TeacherClassPracticeOverview {
|
||||
*
|
||||
* 用于教师端分析页面顶部展示每个班级的练习情况。
|
||||
*
|
||||
* N+1 优化:
|
||||
* - 批量获取所有班级名称(1 条 SQL 替代 N 条)
|
||||
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||
*
|
||||
* @param classIds 教师/年级主任可访问的班级 ID 列表
|
||||
*/
|
||||
export const getTeacherClassPracticeOverviews = cache(async (
|
||||
@@ -278,23 +283,30 @@ export const getTeacherClassPracticeOverviews = cache(async (
|
||||
): Promise<TeacherClassPracticeOverview[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
const results = await Promise.all(
|
||||
classIds.map(async (classId): Promise<TeacherClassPracticeOverview | null> => {
|
||||
const className = await getClassNameById(classId)
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
// 批量获取所有班级名称(1 条 SQL)
|
||||
const classNameMap = await getClassNamesByIds(classIds)
|
||||
|
||||
// 并行获取每个班级的学生 ID 列表
|
||||
const studentIdsPerClass = await Promise.all(
|
||||
classIds.map(async (classId) => ({
|
||||
classId,
|
||||
studentIds: await getActiveStudentIdsByClassId(classId),
|
||||
})),
|
||||
)
|
||||
|
||||
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||
const statsPerClass = await Promise.all(
|
||||
studentIdsPerClass.map(async ({ classId, studentIds }) => {
|
||||
const totalStudents = studentIds.length
|
||||
if (totalStudents === 0) {
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "",
|
||||
totalStudents: 0,
|
||||
totalSessions: 0,
|
||||
completedSessions: 0,
|
||||
totalQuestionsAnswered: 0,
|
||||
totalCorrect: 0,
|
||||
averageAccuracy: 0,
|
||||
activeStudents: 0,
|
||||
totalStudents: 0,
|
||||
participationRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,39 +316,41 @@ export const getTeacherClassPracticeOverviews = cache(async (
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
|
||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||
const totalCorrect = Number(row.totalCorrect)
|
||||
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "",
|
||||
totalSessions: Number(row.totalSessions),
|
||||
completedSessions: Number(row.completedSessions),
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents,
|
||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
||||
totalSessions: Number(row?.totalSessions ?? 0),
|
||||
completedSessions: Number(row?.completedSessions ?? 0),
|
||||
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||
activeStudents: Number(row?.activeStudents ?? 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return results.filter((r): r is TeacherClassPracticeOverview => r !== null)
|
||||
return statsPerClass.map((s) => {
|
||||
const totalQuestionsAnswered = s.totalQuestionsAnswered
|
||||
const totalCorrect = s.totalCorrect
|
||||
const activeStudents = s.activeStudents
|
||||
return {
|
||||
classId: s.classId,
|
||||
className: classNameMap.get(s.classId) ?? "",
|
||||
totalSessions: s.totalSessions,
|
||||
completedSessions: s.completedSessions,
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents: s.totalStudents,
|
||||
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -427,6 +441,10 @@ export interface GradeClassPracticeComparison {
|
||||
/**
|
||||
* 获取年级各班级专项练习对比数据。
|
||||
*
|
||||
* N+1 优化:
|
||||
* - 班级列表已含名称,无需额外查询
|
||||
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||
*
|
||||
* @param gradeId 年级 ID
|
||||
*/
|
||||
export const getGradeClassPracticeComparison = cache(async (
|
||||
@@ -435,23 +453,27 @@ export const getGradeClassPracticeComparison = cache(async (
|
||||
const classList = await getClassesByGradeId(gradeId)
|
||||
if (classList.length === 0) return []
|
||||
|
||||
const results = await Promise.all(
|
||||
classList.map(async (cls): Promise<GradeClassPracticeComparison> => {
|
||||
const studentIds = await getActiveStudentIdsByClassId(cls.id)
|
||||
const totalStudents = studentIds.length
|
||||
// 并行获取每个班级的学生 ID 列表
|
||||
const studentIdsPerClass = await Promise.all(
|
||||
classList.map(async (cls) => ({
|
||||
cls,
|
||||
studentIds: await getActiveStudentIdsByClassId(cls.id),
|
||||
})),
|
||||
)
|
||||
|
||||
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||
const statsPerClass = await Promise.all(
|
||||
studentIdsPerClass.map(async ({ cls, studentIds }) => {
|
||||
const totalStudents = studentIds.length
|
||||
if (totalStudents === 0) {
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
cls,
|
||||
totalStudents: 0,
|
||||
totalSessions: 0,
|
||||
completedSessions: 0,
|
||||
totalQuestionsAnswered: 0,
|
||||
totalCorrect: 0,
|
||||
averageAccuracy: 0,
|
||||
activeStudents: 0,
|
||||
totalStudents: 0,
|
||||
participationRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,36 +483,42 @@ export const getGradeClassPracticeComparison = cache(async (
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const row = rows[0]
|
||||
const totalQuestionsAnswered = Number(row?.totalQuestionsAnswered ?? 0)
|
||||
const totalCorrect = Number(row?.totalCorrect ?? 0)
|
||||
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
||||
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
cls,
|
||||
totalStudents,
|
||||
totalSessions: Number(row?.totalSessions ?? 0),
|
||||
completedSessions: Number(row?.completedSessions ?? 0),
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents,
|
||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
||||
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||
activeStudents: Number(row?.activeStudents ?? 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const results = statsPerClass.map((s) => {
|
||||
const totalQuestionsAnswered = s.totalQuestionsAnswered
|
||||
const totalCorrect = s.totalCorrect
|
||||
const activeStudents = s.activeStudents
|
||||
return {
|
||||
classId: s.cls.id,
|
||||
className: s.cls.name,
|
||||
totalSessions: s.totalSessions,
|
||||
completedSessions: s.completedSessions,
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents: s.totalStudents,
|
||||
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||
}
|
||||
})
|
||||
|
||||
// 按参与率降序排列
|
||||
return results.sort((a, b) => b.participationRate - a.participationRate)
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
questionsToKnowledgePoints,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import { isWeakChapterSourceMeta, isAiRecommendedSourceMeta, isKnowledgePointSourceMeta, isErrorVariantSourceMeta } from "./lib/source-meta"
|
||||
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,22 +23,25 @@ const DEFAULT_QUESTION_COUNT = 10
|
||||
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
|
||||
const WEAK_MASTERY_THRESHOLD = 60
|
||||
|
||||
/** 自动识别薄弱知识点默认返回数量 */
|
||||
const DEFAULT_WEAK_KP_LIMIT = 5
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 出题策略
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 错题变式练习出题策略。
|
||||
* 错题重做出题策略(原"错题变式")。
|
||||
*
|
||||
* 从错题本中选取错题,直接使用原题进行练习(不依赖 AI 生成变式题,
|
||||
* 确保即使 AI 不可用也能练习)。
|
||||
* 从错题本中选取错题,直接使用原题进行练习。
|
||||
* 该策略不依赖 AI 生成变式题,确保即使 AI 不可用也能练习。
|
||||
*
|
||||
* 策略:
|
||||
* 1. 从 sourceQuestionIds 中查询题库中存在的题目
|
||||
* 2. 排除已在该练习会话中的题目(由调用方保证)
|
||||
* 3. 按难度升序排列(先易后难,建立信心)
|
||||
*/
|
||||
async function selectForErrorVariant(
|
||||
export async function selectForErrorVariant(
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
): Promise<QuestionSelectionResult> {
|
||||
@@ -79,7 +83,7 @@ async function selectForErrorVariant(
|
||||
* 2. 按难度筛选(如指定)
|
||||
* 3. 随机抽取指定数量
|
||||
*/
|
||||
async function selectForKnowledgePoint(
|
||||
export async function selectForKnowledgePoint(
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
): Promise<QuestionSelectionResult> {
|
||||
@@ -125,14 +129,14 @@ async function selectForKnowledgePoint(
|
||||
/**
|
||||
* 薄弱章节练习出题策略。
|
||||
*
|
||||
* 根据学生掌握度自动识别薄弱知识点,从这些知识点中抽题。
|
||||
* 行为:
|
||||
* - 若 sourceMeta.weakKnowledgePointIds 已传入,直接使用
|
||||
* - 若未传入,调用 identifyWeakKnowledgePoints(studentId, chapterId) 自动识别
|
||||
*
|
||||
* 策略:
|
||||
* 1. 查询学生在指定章节知识点上的掌握度
|
||||
* 2. 筛选掌握度低于阈值的知识点
|
||||
* 3. 从薄弱知识点中抽题
|
||||
* 然后从(自动识别或显式传入的)薄弱知识点中抽题,
|
||||
* 排除学生已做过的题目避免重复。
|
||||
*/
|
||||
async function selectForWeakChapter(
|
||||
export async function selectForWeakChapter(
|
||||
studentId: string,
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
@@ -141,8 +145,15 @@ async function selectForWeakChapter(
|
||||
return { questionIds: [], variants: new Map() }
|
||||
}
|
||||
|
||||
const { weakKnowledgePointIds } = sourceMeta
|
||||
if (weakKnowledgePointIds.length === 0) {
|
||||
const { chapterId, weakKnowledgePointIds } = sourceMeta
|
||||
|
||||
// 自动识别薄弱知识点(若未传入)
|
||||
let kpIds = weakKnowledgePointIds
|
||||
if (!kpIds || kpIds.length === 0) {
|
||||
kpIds = await identifyWeakKnowledgePoints(studentId, chapterId, DEFAULT_WEAK_KP_LIMIT)
|
||||
}
|
||||
|
||||
if (kpIds.length === 0) {
|
||||
return { questionIds: [], variants: new Map() }
|
||||
}
|
||||
|
||||
@@ -151,7 +162,7 @@ async function selectForWeakChapter(
|
||||
|
||||
// 从薄弱知识点中抽题
|
||||
const conditions = [
|
||||
inArray(questionsToKnowledgePoints.knowledgePointId, weakKnowledgePointIds),
|
||||
inArray(questionsToKnowledgePoints.knowledgePointId, kpIds),
|
||||
sql`${questions.parentId} IS NULL`,
|
||||
]
|
||||
|
||||
@@ -185,7 +196,7 @@ async function selectForWeakChapter(
|
||||
* AI 推荐的知识点列表由上层(AI 分析)提供,
|
||||
* 此函数仅负责从推荐知识点中抽题。
|
||||
*/
|
||||
async function selectForAiRecommended(
|
||||
export async function selectForAiRecommended(
|
||||
studentId: string,
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
@@ -274,7 +285,7 @@ export async function selectQuestionsForPractice(
|
||||
* 从专项练习中汇总已答题目。
|
||||
* 为控制查询量,仅查询最近 1000 条记录。
|
||||
*/
|
||||
async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
|
||||
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ questionId: practiceAnswers.questionId })
|
||||
.from(practiceAnswers)
|
||||
@@ -298,7 +309,7 @@ async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[
|
||||
export async function identifyWeakKnowledgePoints(
|
||||
studentId: string,
|
||||
chapterId?: string,
|
||||
limit: number = 5,
|
||||
limit: number = DEFAULT_WEAK_KP_LIMIT,
|
||||
): Promise<string[]> {
|
||||
const conditions = [
|
||||
eq(knowledgePointMastery.studentId, studentId),
|
||||
@@ -317,27 +328,3 @@ export async function identifyWeakKnowledgePoints(
|
||||
|
||||
return rows.map((r) => r.knowledgePointId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型守卫
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isErrorVariantSourceMeta(meta: PracticeSourceMeta): meta is { errorBookItemIds: string[]; sourceQuestionIds: string[] } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"errorBookItemIds" in meta && "sourceQuestionIds" in meta
|
||||
}
|
||||
|
||||
function isKnowledgePointSourceMeta(meta: PracticeSourceMeta): meta is { knowledgePointIds: string[]; difficulty?: number } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"knowledgePointIds" in meta
|
||||
}
|
||||
|
||||
function isWeakChapterSourceMeta(meta: PracticeSourceMeta): meta is { chapterId: string; weakKnowledgePointIds: string[] } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"chapterId" in meta && "weakKnowledgePointIds" in meta
|
||||
}
|
||||
|
||||
function isAiRecommendedSourceMeta(meta: PracticeSourceMeta): meta is { recommendedKnowledgePointIds: string[]; reason: string } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"recommendedKnowledgePointIds" in meta && "reason" in meta
|
||||
}
|
||||
|
||||
@@ -11,10 +11,17 @@ import {
|
||||
questions,
|
||||
} from "@/shared/db/schema"
|
||||
import { selectQuestionsForPractice } from "./data-access-strategy"
|
||||
import { autoGradeAnswer } from "./lib/grading"
|
||||
import {
|
||||
asPracticeAnswerStatus,
|
||||
asPracticeSourceMeta,
|
||||
asPracticeStatus,
|
||||
asPracticeType,
|
||||
} from "./lib/type-guards"
|
||||
import { practiceErrors } from "./lib/errors"
|
||||
|
||||
import type {
|
||||
PracticeAnswerRecord,
|
||||
PracticeAnswerStatus,
|
||||
PracticeSessionDetail,
|
||||
PracticeSessionSummary,
|
||||
PracticeSourceMeta,
|
||||
@@ -34,8 +41,8 @@ function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessi
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
subjectId: row.subjectId,
|
||||
practiceType: row.practiceType as PracticeType,
|
||||
status: row.status as PracticeStatus,
|
||||
practiceType: asPracticeType(row.practiceType),
|
||||
status: asPracticeStatus(row.status),
|
||||
totalQuestions: row.totalQuestions,
|
||||
answeredQuestions,
|
||||
correctCount,
|
||||
@@ -56,7 +63,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
|
||||
variantContent: row.variantContent,
|
||||
isVariant: row.isVariant,
|
||||
orderIndex: row.orderIndex,
|
||||
status: row.status as PracticeAnswerStatus,
|
||||
status: asPracticeAnswerStatus(row.status),
|
||||
studentAnswer: row.studentAnswer,
|
||||
isCorrect: row.isCorrect,
|
||||
score: row.score,
|
||||
@@ -170,7 +177,7 @@ export const getPracticeSessionById = cache(async (
|
||||
|
||||
return {
|
||||
...summary,
|
||||
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
|
||||
sourceMeta: asPracticeSourceMeta(session.sourceMeta),
|
||||
answers: mappedAnswers,
|
||||
}
|
||||
})
|
||||
@@ -213,7 +220,7 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
||||
}
|
||||
|
||||
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
|
||||
practiceType: type as PracticeType,
|
||||
practiceType: asPracticeType(type),
|
||||
sessionCount: stat.sessionCount,
|
||||
totalQuestions: stat.totalQuestions,
|
||||
correctCount: stat.correctCount,
|
||||
@@ -241,6 +248,8 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
||||
* 2. 创建会话记录
|
||||
* 3. 创建答题记录(初始状态为 pending)
|
||||
*
|
||||
* @throws {PracticeError} 未找到题目时抛 no_questions_found
|
||||
*
|
||||
* @returns 会话 ID 和选中的题目数量
|
||||
*/
|
||||
export async function createPracticeSession(
|
||||
@@ -263,7 +272,7 @@ export async function createPracticeSession(
|
||||
)
|
||||
|
||||
if (selection.questionIds.length === 0) {
|
||||
return { sessionId: "", selectedCount: 0 }
|
||||
throw practiceErrors.noQuestionsFound()
|
||||
}
|
||||
|
||||
const sessionId = createId()
|
||||
@@ -276,6 +285,7 @@ export async function createPracticeSession(
|
||||
studentId,
|
||||
subjectId: input.subjectId ?? null,
|
||||
practiceType,
|
||||
// sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入
|
||||
sourceMeta: sourceMeta as unknown,
|
||||
status: "in_progress",
|
||||
totalQuestions: selection.questionIds.length,
|
||||
@@ -314,6 +324,12 @@ export async function createPracticeSession(
|
||||
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
|
||||
* - 填空题:暂不自动判分(isCorrect = null)
|
||||
*
|
||||
* 并发安全:整个校验+判分+统计更新流程包裹在事务中,
|
||||
* 对答题记录加行锁(SELECT ... FOR UPDATE),
|
||||
* 防止同一答案被并发重复判分导致统计累加错误。
|
||||
*
|
||||
* @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码
|
||||
*
|
||||
* @returns 是否判分成功
|
||||
*/
|
||||
export async function submitPracticeAnswer(
|
||||
@@ -323,95 +339,123 @@ export async function submitPracticeAnswer(
|
||||
answer: unknown,
|
||||
skip: boolean = false,
|
||||
): Promise<{ isCorrect: boolean | null; score: number | null }> {
|
||||
// 校验会话归属
|
||||
const session = await db.query.practiceSessions.findFirst({
|
||||
where: and(
|
||||
eq(practiceSessions.id, sessionId),
|
||||
eq(practiceSessions.studentId, studentId),
|
||||
),
|
||||
})
|
||||
// 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分)
|
||||
return await db.transaction(async (tx) => {
|
||||
// 1. 校验会话归属(带行锁)
|
||||
const [session] = await tx
|
||||
.select()
|
||||
.from(practiceSessions)
|
||||
.where(and(
|
||||
eq(practiceSessions.id, sessionId),
|
||||
eq(practiceSessions.studentId, studentId),
|
||||
))
|
||||
.for("update")
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
}
|
||||
if (!session) {
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
throw new Error("练习会话已结束")
|
||||
}
|
||||
if (session.status !== "in_progress") {
|
||||
throw practiceErrors.sessionEnded()
|
||||
}
|
||||
|
||||
// 查询答题记录
|
||||
const answerRecord = await db.query.practiceAnswers.findFirst({
|
||||
where: and(
|
||||
eq(practiceAnswers.id, answerId),
|
||||
eq(practiceAnswers.sessionId, sessionId),
|
||||
),
|
||||
})
|
||||
// 2. 查询答题记录(带行锁,防止并发重复提交)
|
||||
const [answerRecord] = await tx
|
||||
.select()
|
||||
.from(practiceAnswers)
|
||||
.where(and(
|
||||
eq(practiceAnswers.id, answerId),
|
||||
eq(practiceAnswers.sessionId, sessionId),
|
||||
))
|
||||
.for("update")
|
||||
|
||||
if (!answerRecord) {
|
||||
throw new Error("答题记录不存在")
|
||||
}
|
||||
if (!answerRecord) {
|
||||
throw practiceErrors.answerNotFound()
|
||||
}
|
||||
|
||||
if (answerRecord.status === "answered") {
|
||||
throw new Error("此题已作答")
|
||||
}
|
||||
if (answerRecord.status === "answered") {
|
||||
throw practiceErrors.answerAlreadySubmitted()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const now = new Date()
|
||||
|
||||
if (skip) {
|
||||
// 跳过此题
|
||||
await db
|
||||
if (skip) {
|
||||
// 跳过此题:状态置为 skipped,不累加已答题数与正确数
|
||||
await tx
|
||||
.update(practiceAnswers)
|
||||
.set({
|
||||
status: "skipped",
|
||||
answeredAt: now,
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 累加已答题数(不累加正确数)
|
||||
await tx
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + 1,
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
|
||||
return { isCorrect: null, score: null }
|
||||
}
|
||||
|
||||
// 3. 自动判分:查询题目内容并提取正确答案
|
||||
const [question] = await tx
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(eq(questions.id, answerRecord.questionId))
|
||||
.limit(1)
|
||||
|
||||
if (!question) {
|
||||
throw practiceErrors.questionNotFound()
|
||||
}
|
||||
|
||||
// 如果是变式题,使用变式题内容
|
||||
const contentToUse = answerRecord.variantContent ?? question.content
|
||||
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
|
||||
|
||||
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
|
||||
|
||||
// 4. 更新答题记录
|
||||
await tx
|
||||
.update(practiceAnswers)
|
||||
.set({
|
||||
status: "skipped",
|
||||
status: "answered",
|
||||
studentAnswer: answer,
|
||||
isCorrect,
|
||||
score,
|
||||
answeredAt: now,
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 更新会话统计
|
||||
await updateSessionStats(sessionId, 0, false)
|
||||
return { isCorrect: null, score: null }
|
||||
}
|
||||
// 5. 累加会话统计(基于步骤 1 已加锁的 session 行)
|
||||
await tx
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + 1,
|
||||
correctCount: session.correctCount + (isCorrect === true ? 1 : 0),
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
|
||||
// 自动判分:查询题目内容并提取正确答案
|
||||
const question = await db.query.questions.findFirst({
|
||||
where: eq(questions.id, answerRecord.questionId),
|
||||
return { isCorrect, score }
|
||||
})
|
||||
|
||||
if (!question) {
|
||||
throw new Error("题目不存在")
|
||||
}
|
||||
|
||||
// 如果是变式题,使用变式题内容
|
||||
const contentToUse = answerRecord.variantContent ?? question.content
|
||||
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
|
||||
|
||||
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
|
||||
|
||||
await db
|
||||
.update(practiceAnswers)
|
||||
.set({
|
||||
status: "answered",
|
||||
studentAnswer: answer,
|
||||
isCorrect,
|
||||
score,
|
||||
answeredAt: now,
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 更新会话统计
|
||||
await updateSessionStats(
|
||||
sessionId,
|
||||
1,
|
||||
isCorrect === true,
|
||||
)
|
||||
|
||||
return { isCorrect, score }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:完成/放弃练习会话
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 完成练习会话。
|
||||
*
|
||||
* 完整性校验:必须答完所有题目(answeredQuestions === totalQuestions)才能完成,
|
||||
* 防止学生提前完成导致统计失真。
|
||||
*
|
||||
* 注意:跳过的题目也算"已作答"(status=skipped),与 answeredQuestions 累加逻辑一致。
|
||||
*
|
||||
* @throws {PracticeError} 会话不存在 → session_not_found;未答完 → session_not_complete
|
||||
*/
|
||||
export async function completePracticeSession(
|
||||
sessionId: string,
|
||||
studentId: string,
|
||||
@@ -424,13 +468,19 @@ export async function completePracticeSession(
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
// 已完成或已放弃,幂等返回
|
||||
return
|
||||
}
|
||||
|
||||
// 完整性校验:必须答完所有题目
|
||||
if (session.answeredQuestions !== session.totalQuestions) {
|
||||
throw practiceErrors.sessionNotComplete()
|
||||
}
|
||||
|
||||
await db
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
@@ -440,6 +490,13 @@ export async function completePracticeSession(
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 放弃练习会话。
|
||||
*
|
||||
* 幂等:已完成或已放弃的会话再次调用不会报错。
|
||||
*
|
||||
* @throws {PracticeError} 会话不存在 → session_not_found
|
||||
*/
|
||||
export async function abandonPracticeSession(
|
||||
sessionId: string,
|
||||
studentId: string,
|
||||
@@ -452,10 +509,11 @@ export async function abandonPracticeSession(
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
// 已完成或已放弃,幂等返回
|
||||
return
|
||||
}
|
||||
|
||||
@@ -472,148 +530,5 @@ export async function abandonPracticeSession(
|
||||
// 内部辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 更新会话统计(已答题数、正确数)。
|
||||
*/
|
||||
async function updateSessionStats(
|
||||
sessionId: string,
|
||||
newlyAnswered: number,
|
||||
newlyCorrect: boolean,
|
||||
): Promise<void> {
|
||||
const session = await db.query.practiceSessions.findFirst({
|
||||
where: eq(practiceSessions.id, sessionId),
|
||||
columns: {
|
||||
answeredQuestions: true,
|
||||
correctCount: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!session) return
|
||||
|
||||
await db
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + newlyAnswered,
|
||||
correctCount: session.correctCount + (newlyCorrect ? 1 : 0),
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动判分:比对学生答案与正确答案。
|
||||
*
|
||||
* 支持题型:
|
||||
* - single_choice: 比对选中选项 ID
|
||||
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
|
||||
* - judgment: 比对布尔值
|
||||
* - text: 不自动判分(返回 null)
|
||||
*
|
||||
* @param questionType 题目类型
|
||||
* @param content 题目内容(或变式题内容)
|
||||
* @param studentAnswer 学生答案
|
||||
* @returns 是否正确(null 表示无法自动判分)
|
||||
*/
|
||||
function autoGradeAnswer(
|
||||
questionType: string,
|
||||
content: unknown,
|
||||
studentAnswer: unknown,
|
||||
): boolean | null {
|
||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
||||
const correctIds = extractChoiceCorrectIds(content)
|
||||
if (correctIds.length === 0) return null
|
||||
|
||||
const studentIds = normalizeAnswerToIds(studentAnswer)
|
||||
if (studentIds.length === 0) return false
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
return studentIds.length === 1 && studentIds[0] === correctIds[0]
|
||||
}
|
||||
|
||||
// multiple_choice: 集合比对
|
||||
if (studentIds.length !== correctIds.length) return false
|
||||
const correctSet = new Set(correctIds)
|
||||
return studentIds.every((id) => correctSet.has(id))
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const correctAnswer = extractJudgmentCorrectAnswer(content)
|
||||
if (correctAnswer === null) return null
|
||||
|
||||
const studentBool = normalizeAnswerToBool(studentAnswer)
|
||||
if (studentBool === null) return null
|
||||
|
||||
return studentBool === correctAnswer
|
||||
}
|
||||
|
||||
// text 题型不自动判分
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取选择题正确选项 ID 列表。
|
||||
*/
|
||||
function extractChoiceCorrectIds(content: unknown): string[] {
|
||||
if (!isRecord(content)) return []
|
||||
|
||||
const options = content.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt: unknown) => isRecord(opt) && opt.isCorrect === true)
|
||||
.map((opt: unknown) => {
|
||||
const record = opt as Record<string, unknown>
|
||||
return typeof record.id === "string" ? record.id : ""
|
||||
})
|
||||
.filter((id: string) => id.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取判断题正确答案。
|
||||
*/
|
||||
function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
|
||||
if (!isRecord(content)) return null
|
||||
|
||||
const answer = content.answer
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为选项 ID 列表。
|
||||
*/
|
||||
function normalizeAnswerToIds(answer: unknown): string[] {
|
||||
if (typeof answer === "string") return [answer]
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (isRecord(answer)) {
|
||||
const ids = answer.selectedIds
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (typeof answer.id === "string") return [answer.id]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为布尔值。
|
||||
*/
|
||||
function normalizeAnswerToBool(answer: unknown): boolean | null {
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
// 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts,便于单测与复用。
|
||||
// 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性。
|
||||
|
||||
13
src/modules/adaptive-practice/lib/answer-utils.ts
Normal file
13
src/modules/adaptive-practice/lib/answer-utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Adaptive Practice 答题相关工具函数。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 判断值是否为非空字符串数组。
|
||||
*
|
||||
* 用于 multiple_choice 题型的学生答案类型收窄,
|
||||
* 替代 `as string[]` 断言。
|
||||
*/
|
||||
export function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === "string")
|
||||
}
|
||||
58
src/modules/adaptive-practice/lib/errors.ts
Normal file
58
src/modules/adaptive-practice/lib/errors.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Adaptive Practice 模块错误类型。
|
||||
*
|
||||
* 用结构化错误码替代硬编码中文 message,
|
||||
* 前端通过 errorCode 查 i18n 翻译键展示。
|
||||
*
|
||||
* 继承 BusinessError,使 handleActionError 能自动保留 errorCode。
|
||||
*/
|
||||
|
||||
import { BusinessError } from "@/shared/lib/action-utils"
|
||||
|
||||
export const PRACTICE_ERROR_CODES = [
|
||||
"session_not_found",
|
||||
"session_ended",
|
||||
"answer_not_found",
|
||||
"answer_already_submitted",
|
||||
"question_not_found",
|
||||
"session_not_complete",
|
||||
"no_questions_found",
|
||||
"forbidden",
|
||||
] as const
|
||||
|
||||
export type PracticeErrorCode = (typeof PRACTICE_ERROR_CODES)[number]
|
||||
|
||||
/**
|
||||
* 业务错误:携带 errorCode 的错误类。
|
||||
*
|
||||
* 用于 data-access 层抛错,actions 层通过 handleActionError 捕获后
|
||||
* 转换为 ActionState.errorCode 返回前端,前端 t(`errors.${errorCode}`) 查翻译。
|
||||
*/
|
||||
export class PracticeError extends BusinessError {
|
||||
readonly code: PracticeErrorCode
|
||||
|
||||
constructor(code: PracticeErrorCode) {
|
||||
// message 留空,前端通过 errorCode 查 i18n 文案
|
||||
super(code, code)
|
||||
this.name = "PracticeError"
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function isPracticeError(e: unknown): e is PracticeError {
|
||||
return e instanceof PracticeError
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷工厂函数。
|
||||
*/
|
||||
export const practiceErrors = {
|
||||
sessionNotFound: () => new PracticeError("session_not_found"),
|
||||
sessionEnded: () => new PracticeError("session_ended"),
|
||||
answerNotFound: () => new PracticeError("answer_not_found"),
|
||||
answerAlreadySubmitted: () => new PracticeError("answer_already_submitted"),
|
||||
questionNotFound: () => new PracticeError("question_not_found"),
|
||||
sessionNotComplete: () => new PracticeError("session_not_complete"),
|
||||
noQuestionsFound: () => new PracticeError("no_questions_found"),
|
||||
forbidden: () => new PracticeError("forbidden"),
|
||||
}
|
||||
142
src/modules/adaptive-practice/lib/grading.ts
Normal file
142
src/modules/adaptive-practice/lib/grading.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Adaptive Practice 自动判分纯函数。
|
||||
*
|
||||
* 从 data-access.ts 抽取为独立模块,便于单测与复用。
|
||||
*
|
||||
* 支持题型:
|
||||
* - single_choice: 比对选中选项 ID
|
||||
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
|
||||
* - judgment: 比对布尔值
|
||||
* - text: 不自动判分(返回 null)
|
||||
*/
|
||||
|
||||
import { isRecord } from "@/shared/lib/type-guards"
|
||||
|
||||
/**
|
||||
* 自动判分:比对学生答案与正确答案。
|
||||
*
|
||||
* @param questionType 题目类型
|
||||
* @param content 题目内容(或变式题内容)
|
||||
* @param studentAnswer 学生答案
|
||||
* @returns 是否正确(null 表示无法自动判分)
|
||||
*/
|
||||
export function autoGradeAnswer(
|
||||
questionType: string,
|
||||
content: unknown,
|
||||
studentAnswer: unknown,
|
||||
): boolean | null {
|
||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
||||
const correctIds = extractChoiceCorrectIds(content)
|
||||
if (correctIds.length === 0) return null
|
||||
|
||||
const studentIds = normalizeAnswerToIds(studentAnswer)
|
||||
if (studentIds.length === 0) return false
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
return studentIds.length === 1 && studentIds[0] === correctIds[0]
|
||||
}
|
||||
|
||||
// multiple_choice: 集合比对
|
||||
if (studentIds.length !== correctIds.length) return false
|
||||
const correctSet = new Set(correctIds)
|
||||
return studentIds.every((id) => correctSet.has(id))
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const correctAnswer = extractJudgmentCorrectAnswer(content)
|
||||
if (correctAnswer === null) return null
|
||||
|
||||
const studentBool = normalizeAnswerToBool(studentAnswer)
|
||||
if (studentBool === null) return null
|
||||
|
||||
return studentBool === correctAnswer
|
||||
}
|
||||
|
||||
// text 题型不自动判分
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取选择题正确选项 ID 列表。
|
||||
*/
|
||||
export function extractChoiceCorrectIds(content: unknown): string[] {
|
||||
if (!isRecord(content)) return []
|
||||
|
||||
const options = content.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt: unknown): opt is Record<string, unknown> =>
|
||||
isRecord(opt) && opt.isCorrect === true,
|
||||
)
|
||||
.map((opt) => {
|
||||
const id = opt.id
|
||||
return typeof id === "string" ? id : ""
|
||||
})
|
||||
.filter((id) => id.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取判断题正确答案。
|
||||
*/
|
||||
export function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
|
||||
if (!isRecord(content)) return null
|
||||
|
||||
const answer = content.answer
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为选项 ID 列表。
|
||||
*/
|
||||
export function normalizeAnswerToIds(answer: unknown): string[] {
|
||||
if (typeof answer === "string") return [answer]
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (isRecord(answer)) {
|
||||
const ids = answer.selectedIds
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (typeof answer.id === "string") return [answer.id]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为布尔值。
|
||||
*/
|
||||
export function normalizeAnswerToBool(answer: unknown): boolean | null {
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取选项列表(用于 UI 渲染)。
|
||||
*/
|
||||
export function extractOptions(content: unknown): Array<{ id: string; text: string }> {
|
||||
if (!isRecord(content)) return []
|
||||
const options = content.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt): opt is Record<string, unknown> =>
|
||||
typeof opt === "object" && opt !== null && typeof opt.id === "string",
|
||||
)
|
||||
.map((opt) => ({
|
||||
id: opt.id as string,
|
||||
text: typeof opt.text === "string" ? opt.text : String(opt.text ?? ""),
|
||||
}))
|
||||
}
|
||||
114
src/modules/adaptive-practice/lib/source-meta.ts
Normal file
114
src/modules/adaptive-practice/lib/source-meta.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Adaptive Practice 来源元数据类型守卫。
|
||||
*
|
||||
* 从 data-access-strategy.ts 抽取为独立模块,便于单测与跨模块复用。
|
||||
*
|
||||
* 严格校验 PracticeSourceMeta 联合类型的字段结构,
|
||||
* 替代原先仅检查 key 存在性的弱校验。
|
||||
*/
|
||||
|
||||
import { isRecord } from "@/shared/lib/type-guards"
|
||||
|
||||
import type {
|
||||
AiRecommendedSourceMeta,
|
||||
ErrorVariantSourceMeta,
|
||||
KnowledgePointSourceMeta,
|
||||
PracticeSourceMeta,
|
||||
WeakChapterSourceMeta,
|
||||
} from "../types"
|
||||
|
||||
/**
|
||||
* 判断值是否为非空字符串数组。
|
||||
*/
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === "string")
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 sourceMeta 是否为错题重做类型。
|
||||
*
|
||||
* 严格校验 errorBookItemIds 与 sourceQuestionIds 均为非空 string[]。
|
||||
*/
|
||||
export function isErrorVariantSourceMeta(meta: unknown): meta is ErrorVariantSourceMeta {
|
||||
if (!isRecord(meta)) return false
|
||||
return isStringArray(meta.errorBookItemIds) && isStringArray(meta.sourceQuestionIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 sourceMeta 是否为知识点专项类型。
|
||||
*
|
||||
* 严格校验 knowledgePointIds 为非空 string[],difficulty 为可选 1-5 数字。
|
||||
*/
|
||||
export function isKnowledgePointSourceMeta(meta: unknown): meta is KnowledgePointSourceMeta {
|
||||
if (!isRecord(meta)) return false
|
||||
if (!isStringArray(meta.knowledgePointIds)) return false
|
||||
if (meta.difficulty !== undefined) {
|
||||
if (typeof meta.difficulty !== "number") return false
|
||||
if (!Number.isInteger(meta.difficulty)) return false
|
||||
if (meta.difficulty < 1 || meta.difficulty > 5) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 sourceMeta 是否为薄弱章节类型。
|
||||
*
|
||||
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点;
|
||||
* 若传入必须为非空 string。
|
||||
* weakKnowledgePointIds 可选——未传时由 data-access 自动识别;
|
||||
* 若存在必须为非空 string[]。
|
||||
*/
|
||||
export function isWeakChapterSourceMeta(meta: unknown): meta is WeakChapterSourceMeta {
|
||||
if (!isRecord(meta)) return false
|
||||
// chapterId 可选:未传 → undefined;若传必须为非空 string
|
||||
if (meta.chapterId !== undefined) {
|
||||
if (typeof meta.chapterId !== "string" || meta.chapterId.length === 0) return false
|
||||
}
|
||||
// weakKnowledgePointIds 可选:未传 → undefined;若传必须为非空 string[]
|
||||
if (meta.weakKnowledgePointIds === undefined) return true
|
||||
return isStringArray(meta.weakKnowledgePointIds)
|
||||
}
|
||||
|
||||
/** AI 推荐理由枚举集合(与 AiRecommendedReason 类型对齐) */
|
||||
const AI_RECOMMENDED_REASONS = new Set([
|
||||
"student_initiated",
|
||||
"teacher_assigned",
|
||||
"parent_suggested",
|
||||
])
|
||||
|
||||
/**
|
||||
* 判断 sourceMeta 是否为 AI 推荐类型。
|
||||
*
|
||||
* 严格校验 recommendedKnowledgePointIds 为非空 string[],
|
||||
* reason 为 AiRecommendedReason 枚举值(业务数据,UI 层翻译)。
|
||||
*/
|
||||
export function isAiRecommendedSourceMeta(meta: unknown): meta is AiRecommendedSourceMeta {
|
||||
if (!isRecord(meta)) return false
|
||||
if (!isStringArray(meta.recommendedKnowledgePointIds)) return false
|
||||
return typeof meta.reason === "string" && AI_RECOMMENDED_REASONS.has(meta.reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据练习类型字符串尝试收窄 sourceMeta。
|
||||
*
|
||||
* 用于在 actions 层替代 `as unknown as PracticeSourceMeta` 断言。
|
||||
*
|
||||
* @returns 收窄后的 sourceMeta;如果类型不匹配返回 null。
|
||||
*/
|
||||
export function parseSourceMeta(
|
||||
practiceType: string,
|
||||
raw: unknown,
|
||||
): PracticeSourceMeta | null {
|
||||
switch (practiceType) {
|
||||
case "error_variant":
|
||||
return isErrorVariantSourceMeta(raw) ? raw : null
|
||||
case "knowledge_point":
|
||||
return isKnowledgePointSourceMeta(raw) ? raw : null
|
||||
case "weak_chapter":
|
||||
return isWeakChapterSourceMeta(raw) ? raw : null
|
||||
case "ai_recommended":
|
||||
return isAiRecommendedSourceMeta(raw) ? raw : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
105
src/modules/adaptive-practice/lib/type-guards.ts
Normal file
105
src/modules/adaptive-practice/lib/type-guards.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Adaptive Practice 练习类型与状态类型守卫。
|
||||
*
|
||||
* 替代 data-access.ts 中的 `as` 断言,从 DB 取出的 enum 字段
|
||||
* 通过类型守卫严格校验后再使用。
|
||||
*/
|
||||
|
||||
import {
|
||||
isAiRecommendedSourceMeta,
|
||||
isErrorVariantSourceMeta,
|
||||
isKnowledgePointSourceMeta,
|
||||
isWeakChapterSourceMeta,
|
||||
} from "./source-meta"
|
||||
|
||||
import type {
|
||||
PracticeAnswerStatus,
|
||||
PracticeSourceMeta,
|
||||
PracticeStatus,
|
||||
PracticeType,
|
||||
} from "../types"
|
||||
|
||||
const PRACTICE_TYPES = new Set<PracticeType>([
|
||||
"error_variant",
|
||||
"knowledge_point",
|
||||
"weak_chapter",
|
||||
"ai_recommended",
|
||||
])
|
||||
|
||||
const PRACTICE_STATUSES = new Set<PracticeStatus>([
|
||||
"in_progress",
|
||||
"completed",
|
||||
"abandoned",
|
||||
])
|
||||
|
||||
const PRACTICE_ANSWER_STATUSES = new Set<PracticeAnswerStatus>([
|
||||
"pending",
|
||||
"answered",
|
||||
"skipped",
|
||||
])
|
||||
|
||||
export function isPracticeType(value: unknown): value is PracticeType {
|
||||
return typeof value === "string" && PRACTICE_TYPES.has(value as PracticeType)
|
||||
}
|
||||
|
||||
export function isPracticeStatus(value: unknown): value is PracticeStatus {
|
||||
return typeof value === "string" && PRACTICE_STATUSES.has(value as PracticeStatus)
|
||||
}
|
||||
|
||||
export function isPracticeAnswerStatus(value: unknown): value is PracticeAnswerStatus {
|
||||
return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value as PracticeAnswerStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换 DB 行的 practiceType 字段。
|
||||
*
|
||||
* @throws {Error} 当值不是合法 PracticeType
|
||||
*/
|
||||
export function asPracticeType(value: unknown): PracticeType {
|
||||
if (!isPracticeType(value)) {
|
||||
throw new Error(`Invalid practice type: ${String(value)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换 DB 行的 status 字段。
|
||||
*
|
||||
* @throws {Error} 当值不是合法 PracticeStatus
|
||||
*/
|
||||
export function asPracticeStatus(value: unknown): PracticeStatus {
|
||||
if (!isPracticeStatus(value)) {
|
||||
throw new Error(`Invalid practice status: ${String(value)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换 DB 行的 answer status 字段。
|
||||
*
|
||||
* @throws {Error} 当值不是合法 PracticeAnswerStatus
|
||||
*/
|
||||
export function asPracticeAnswerStatus(value: unknown): PracticeAnswerStatus {
|
||||
if (!isPracticeAnswerStatus(value)) {
|
||||
throw new Error(`Invalid practice answer status: ${String(value)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换 DB 行的 sourceMeta 字段。
|
||||
*
|
||||
* 不假设 practiceType 上下文,依次尝试四种来源元数据类型守卫,
|
||||
* 任意一个通过即返回收窄后的值;DB 为 null 或结构不合法时返回 null。
|
||||
*
|
||||
* 替代 `value as PracticeSourceMeta` 断言,避免脏数据导致运行时崩溃。
|
||||
*/
|
||||
export function asPracticeSourceMeta(value: unknown): PracticeSourceMeta | null {
|
||||
if (value === null || value === undefined) return null
|
||||
if (isErrorVariantSourceMeta(value)) return value
|
||||
if (isKnowledgePointSourceMeta(value)) return value
|
||||
if (isWeakChapterSourceMeta(value)) return value
|
||||
if (isAiRecommendedSourceMeta(value)) return value
|
||||
// 结构不匹配任何已知类型,视为损坏数据
|
||||
return null
|
||||
}
|
||||
@@ -14,29 +14,83 @@ export const PracticeTypeSchema = z.enum([
|
||||
export const PracticeAnswerStatusSchema = z.enum(["pending", "answered", "skipped"])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 来源元数据验证
|
||||
// 来源元数据验证(判别式联合类型,替代 z.record(z.string(), z.unknown()))
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 错题重做练习来源。
|
||||
*
|
||||
* practiceType="error_variant" 时使用。
|
||||
*/
|
||||
export const ErrorVariantSourceMetaSchema = z.object({
|
||||
errorBookItemIds: z.array(z.string().min(1)).min(1),
|
||||
sourceQuestionIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* 知识点专项练习来源。
|
||||
*
|
||||
* practiceType="knowledge_point" 时使用。
|
||||
*/
|
||||
export const KnowledgePointSourceMetaSchema = z.object({
|
||||
knowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||
difficulty: z.number().int().min(1).max(5).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 薄弱章节练习来源。
|
||||
*
|
||||
* practiceType="weak_chapter" 时使用。
|
||||
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点。
|
||||
* weakKnowledgePointIds 可选——未传时由后端自动识别薄弱知识点。
|
||||
*/
|
||||
export const WeakChapterSourceMetaSchema = z.object({
|
||||
chapterId: z.string().min(1),
|
||||
weakKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||
chapterId: z.string().min(1).optional(),
|
||||
weakKnowledgePointIds: z.array(z.string().min(1)).min(1).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AI 推荐练习来源。
|
||||
*
|
||||
* practiceType="ai_recommended" 时使用。
|
||||
* reason 为枚举字符串(业务数据,不应直接存翻译文本)。
|
||||
*/
|
||||
export const AiRecommendedReasonSchema = z.enum([
|
||||
"student_initiated",
|
||||
"teacher_assigned",
|
||||
"parent_suggested",
|
||||
])
|
||||
|
||||
export const AiRecommendedSourceMetaSchema = z.object({
|
||||
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||
reason: z.string().min(1),
|
||||
reason: AiRecommendedReasonSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* 判别式联合类型 schema。
|
||||
*
|
||||
* 通过 practiceType 字段判别,确保 sourceMeta 结构与练习类型匹配。
|
||||
* 替代原先的 z.record(z.string(), z.unknown()) 弱类型校验。
|
||||
*/
|
||||
export const PracticeSourceMetaSchema = z.discriminatedUnion("practiceType", [
|
||||
z.object({
|
||||
practiceType: z.literal("error_variant"),
|
||||
sourceMeta: ErrorVariantSourceMetaSchema,
|
||||
}),
|
||||
z.object({
|
||||
practiceType: z.literal("knowledge_point"),
|
||||
sourceMeta: KnowledgePointSourceMetaSchema,
|
||||
}),
|
||||
z.object({
|
||||
practiceType: z.literal("weak_chapter"),
|
||||
sourceMeta: WeakChapterSourceMetaSchema,
|
||||
}),
|
||||
z.object({
|
||||
practiceType: z.literal("ai_recommended"),
|
||||
sourceMeta: AiRecommendedSourceMetaSchema,
|
||||
}),
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action 输入验证
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,3 +126,4 @@ export type CreatePracticeSessionInput = z.infer<typeof CreatePracticeSessionSch
|
||||
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
|
||||
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
|
||||
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema>
|
||||
export type AiRecommendedReason = z.infer<typeof AiRecommendedReasonSchema>
|
||||
|
||||
160
src/modules/adaptive-practice/services/practice-service.tsx
Normal file
160
src/modules/adaptive-practice/services/practice-service.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import {
|
||||
abandonPracticeSessionAction,
|
||||
completePracticeSessionAction,
|
||||
createPracticeSessionAction,
|
||||
getPracticeSessionDetailAction,
|
||||
getPracticeSessionsAction,
|
||||
getPracticeStatsAction,
|
||||
submitPracticeAnswerAction,
|
||||
} from "../actions"
|
||||
import type {
|
||||
PracticeSessionDetail,
|
||||
PracticeSessionSummary,
|
||||
PracticeStats,
|
||||
} from "../types"
|
||||
|
||||
// ─── 数据服务接口 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 专项练习数据服务接口(抽象数据依赖)。
|
||||
*
|
||||
* 组件层通过 `usePracticeService()` 消费此接口,而非直接 import actions,
|
||||
* 实现完全解耦:
|
||||
* - 默认实现调用真实 Server Actions(见下方 `defaultPracticeService`)
|
||||
* - 测试时可注入 mock 实现以隔离数据层
|
||||
* - 不同角色(student / parent / teacher)可注入不同实现,
|
||||
* 例如 ParentPracticeService 在调用时自动附加子女 studentId
|
||||
*
|
||||
* 所有写入方法签名与 Server Action 保持一致(prevState + formData),
|
||||
* 便于无缝替换;读取方法直接返回数据,由 Server Action 内部处理权限校验。
|
||||
*/
|
||||
export interface PracticeService {
|
||||
/** 创建练习会话 */
|
||||
createSession(
|
||||
prevState: ActionState<{ sessionId: string; selectedCount: number }> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ sessionId: string; selectedCount: number }>>
|
||||
/** 提交单题答案 */
|
||||
submitAnswer(
|
||||
prevState: ActionState<{ isCorrect: boolean | null; score: number | null }> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ isCorrect: boolean | null; score: number | null }>>
|
||||
/** 完成练习会话 */
|
||||
completeSession(
|
||||
prevState: ActionState<void> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<void>>
|
||||
/** 放弃练习会话 */
|
||||
abandonSession(
|
||||
prevState: ActionState<void> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<void>>
|
||||
/** 获取练习会话列表 */
|
||||
getSessions(
|
||||
studentId?: string,
|
||||
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>>
|
||||
/** 获取练习会话详情(含答题记录) */
|
||||
getSessionDetail(
|
||||
sessionId: string,
|
||||
studentId?: string,
|
||||
): Promise<ActionState<PracticeSessionDetail>>
|
||||
/** 获取练习统计 */
|
||||
getStats(studentId?: string): Promise<ActionState<PracticeStats>>
|
||||
}
|
||||
|
||||
// ─── 监控埋点接口 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 专项练习监控埋点接口。
|
||||
*
|
||||
* 预留关键操作埋点,供后续接入实际监控 SDK(如 PostHog / Mixpanel)。
|
||||
* 默认实现为空操作(见 `noopPracticeAnalytics`),生产环境通过
|
||||
* Provider 注入实际实现。
|
||||
*/
|
||||
export interface PracticeAnalytics {
|
||||
/** 练习会话创建 */
|
||||
trackSessionStart(practiceType: string, questionCount: number): void
|
||||
/** 单题答案提交 */
|
||||
trackAnswerSubmit(sessionId: string, answerId: string, isCorrect: boolean | null): void
|
||||
/** 练习会话完成 */
|
||||
trackSessionComplete(sessionId: string, accuracy: number): void
|
||||
/** 练习会话放弃 */
|
||||
trackSessionAbandon(sessionId: string): void
|
||||
/** 提交失败重试 */
|
||||
trackErrorRetry(answerId: string): void
|
||||
}
|
||||
|
||||
const noopPracticeAnalytics: PracticeAnalytics = {
|
||||
trackSessionStart: () => {},
|
||||
trackAnswerSubmit: () => {},
|
||||
trackSessionComplete: () => {},
|
||||
trackSessionAbandon: () => {},
|
||||
trackErrorRetry: () => {},
|
||||
}
|
||||
|
||||
// ─── 默认实现(代理到真实 Server Actions) ──────────────────
|
||||
|
||||
/**
|
||||
* 默认 PracticeService 实现:直接代理到真实 Server Actions。
|
||||
*
|
||||
* 测试或角色定制时可在 Provider 中注入其他实现以覆盖此默认行为。
|
||||
*/
|
||||
export const defaultPracticeService: PracticeService = {
|
||||
createSession: (prevState, formData) => createPracticeSessionAction(prevState, formData),
|
||||
submitAnswer: (prevState, formData) => submitPracticeAnswerAction(prevState, formData),
|
||||
completeSession: (prevState, formData) => completePracticeSessionAction(prevState, formData),
|
||||
abandonSession: (prevState, formData) => abandonPracticeSessionAction(prevState, formData),
|
||||
getSessions: (studentId) => getPracticeSessionsAction(studentId),
|
||||
getSessionDetail: (sessionId, studentId) =>
|
||||
getPracticeSessionDetailAction(sessionId, studentId),
|
||||
getStats: (studentId) => getPracticeStatsAction(studentId),
|
||||
}
|
||||
|
||||
// ─── React Context 依赖注入 ────────────────────────────────
|
||||
|
||||
const PracticeServiceContext = createContext<PracticeService | null>(null)
|
||||
const PracticeAnalyticsContext = createContext<PracticeAnalytics>(noopPracticeAnalytics)
|
||||
|
||||
interface PracticeServiceProviderProps {
|
||||
/** 注入的服务实现;不传则使用默认实现(调用真实 Server Actions) */
|
||||
service?: PracticeService
|
||||
/** 注入的监控埋点实现;不传则使用空操作 */
|
||||
analytics?: PracticeAnalytics
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 专项练习服务 Provider(在页面层注入角色特定的实现)。
|
||||
*
|
||||
* 未包裹 Provider 时 `usePracticeService()` 回退到默认实现,保证向后兼容。
|
||||
*/
|
||||
export function PracticeServiceProvider({
|
||||
service,
|
||||
analytics,
|
||||
children,
|
||||
}: PracticeServiceProviderProps): ReactNode {
|
||||
return (
|
||||
<PracticeServiceContext.Provider value={service ?? defaultPracticeService}>
|
||||
<PracticeAnalyticsContext.Provider value={analytics ?? noopPracticeAnalytics}>
|
||||
{children}
|
||||
</PracticeAnalyticsContext.Provider>
|
||||
</PracticeServiceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取当前注入的专项练习数据服务 */
|
||||
export function usePracticeService(): PracticeService {
|
||||
const ctx = useContext(PracticeServiceContext)
|
||||
return ctx ?? defaultPracticeService
|
||||
}
|
||||
|
||||
/** 获取当前注入的监控埋点接口 */
|
||||
export function usePracticeAnalytics(): PracticeAnalytics {
|
||||
return useContext(PracticeAnalyticsContext)
|
||||
}
|
||||
@@ -33,19 +33,28 @@ export interface KnowledgePointSourceMeta {
|
||||
|
||||
/** 薄弱章节练习来源 */
|
||||
export interface WeakChapterSourceMeta {
|
||||
chapterId: string
|
||||
/** 自动识别的薄弱知识点 */
|
||||
weakKnowledgePointIds: string[]
|
||||
/** 章节 ID(可选;未传时跨所有章节自动识别薄弱知识点) */
|
||||
chapterId?: string
|
||||
/** 自动识别的薄弱知识点(可选;未传时由 data-access 自动识别) */
|
||||
weakKnowledgePointIds?: string[]
|
||||
}
|
||||
|
||||
/** AI 推荐练习来源 */
|
||||
export interface AiRecommendedSourceMeta {
|
||||
/** AI 推荐的知识点列表 */
|
||||
recommendedKnowledgePointIds: string[]
|
||||
/** 推荐理由 */
|
||||
reason: string
|
||||
/** 推荐理由(枚举值,UI 层翻译) */
|
||||
reason: AiRecommendedReason
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 推荐理由枚举。
|
||||
*
|
||||
* 业务数据,存数据库,UI 层通过 t(`reasons.${reason}`) 查 i18n。
|
||||
* 严禁存翻译文本到数据库。
|
||||
*/
|
||||
export type AiRecommendedReason = "student_initiated" | "teacher_assigned" | "parent_suggested"
|
||||
|
||||
export type PracticeSourceMeta =
|
||||
| ErrorVariantSourceMeta
|
||||
| KnowledgePointSourceMeta
|
||||
|
||||
Reference in New Issue
Block a user