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:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

View File

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

View 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")}
/>
)
}

View 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>
)
}

View File

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

View File

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

View File

@@ -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 ?? ""),
}))
}

View File

@@ -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} />
}

View File

@@ -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>

View 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>
)
}

View File

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

View File

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

View File

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

View File

@@ -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 事务中,确保原子性

View 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")
}

View 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"),
}

View 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 ?? ""),
}))
}

View 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
}
}

View 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
}

View File

@@ -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>

View 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)
}

View File

@@ -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