feat(adaptive-practice): add new adaptive practice module

- Add adaptive practice module with data-access, schema, types, and components

- Provides personalized practice based on student performance and error patterns
This commit is contained in:
SpecialX
2026-06-24 12:02:04 +08:00
parent 9783be58c0
commit d7876c5854
16 changed files with 3691 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import {
CreatePracticeSessionSchema,
SubmitPracticeAnswerSchema,
CompletePracticeSessionSchema,
AbandonPracticeSessionSchema,
} from "./schema"
import {
createPracticeSession,
submitPracticeAnswer,
completePracticeSession,
abandonPracticeSession,
getPracticeSessionById,
getPracticeSessions,
getPracticeStats,
} from "./data-access"
import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types"
// ---------------------------------------------------------------------------
// 查询 Actions
// ---------------------------------------------------------------------------
export async function getPracticeSessionsAction(
studentId?: string,
): 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 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 }
}
}
export async function getPracticeSessionDetailAction(
sessionId: string,
studentId?: string,
): 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 data = await getPracticeSessionById(sessionId, targetStudentId)
if (!data) {
return { success: false, message: "练习会话不存在或无权访问" }
}
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 }
}
}
export async function getPracticeStatsAction(
studentId?: string,
): 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 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 }
}
}
// ---------------------------------------------------------------------------
// 写入 Actions
// ---------------------------------------------------------------------------
export async function createPracticeSessionAction(
prevState: ActionState<{ sessionId: string; selectedCount: number }> | undefined,
formData: FormData,
): Promise<ActionState<{ sessionId: string; selectedCount: number }>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE)
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "提交格式错误,需要 JSON 字段" }
}
const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
if (!parsed.success) {
return {
success: false,
message: "输入验证失败",
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
const result = await createPracticeSession(ctx.userId, {
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) {
return handleActionError(e)
}
}
export async function submitPracticeAnswerAction(
prevState: ActionState<{ isCorrect: boolean | null; score: number | null }> | undefined,
formData: FormData,
): Promise<ActionState<{ isCorrect: boolean | null; score: number | null }>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE)
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "提交格式错误" }
}
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
if (!parsed.success) {
return {
success: false,
message: "输入验证失败",
errors: parsed.error.flatten().fieldErrors,
}
}
const { sessionId, answerId, answer, skip } = parsed.data
const result = await submitPracticeAnswer(
sessionId,
ctx.userId,
answerId,
answer,
skip ?? false,
)
revalidatePath(`/student/practice/${sessionId}`)
return {
success: true,
message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"),
data: result,
}
} catch (e) {
return handleActionError(e)
}
}
export async function completePracticeSessionAction(
prevState: ActionState<void> | undefined,
formData: FormData,
): Promise<ActionState<void>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE)
const parsed = CompletePracticeSessionSchema.safeParse({
sessionId: formData.get("sessionId"),
})
if (!parsed.success) {
return {
success: false,
message: "输入验证失败",
errors: parsed.error.flatten().fieldErrors,
}
}
await completePracticeSession(parsed.data.sessionId, ctx.userId)
revalidatePath("/student/practice")
revalidatePath(`/student/practice/${parsed.data.sessionId}`)
return { success: true, message: "练习已完成" }
} catch (e) {
return handleActionError(e)
}
}
export async function abandonPracticeSessionAction(
prevState: ActionState<void> | undefined,
formData: FormData,
): Promise<ActionState<void>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_MANAGE)
const parsed = AbandonPracticeSessionSchema.safeParse({
sessionId: formData.get("sessionId"),
})
if (!parsed.success) {
return {
success: false,
message: "输入验证失败",
errors: parsed.error.flatten().fieldErrors,
}
}
await abandonPracticeSession(parsed.data.sessionId, ctx.userId)
revalidatePath("/student/practice")
return { success: true, message: "练习已放弃" }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -0,0 +1,144 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import { BarChart3 } from "lucide-react"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/ui/chart"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn } from "@/shared/lib/utils"
import type { ClassKnowledgePointWeakness } from "@/modules/adaptive-practice/data-access-analytics"
interface ClassKnowledgePointWeaknessChartProps {
data: ClassKnowledgePointWeakness[]
className?: string
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
]
/**
* 班级知识点薄弱度柱状图(教师视图)
*
* 横轴:知识点名称,纵轴:错误率
* tooltip 显示答题数、错题数、错误率
*/
export function ClassKnowledgePointWeaknessChart({
data,
className,
}: ClassKnowledgePointWeaknessChartProps) {
const t = useTranslations("practice")
if (data.length === 0) {
return (
<Card className={cn("shadow-none", className)}>
<CardHeader>
<CardTitle className="text-base">{t("teacher.knowledgePointWeakness.title")}</CardTitle>
<CardDescription>{t("teacher.knowledgePointWeakness.description")}</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title={t("teacher.knowledgePointWeakness.noData")}
description={t("teacher.knowledgePointWeakness.noDataDescription")}
className="h-[280px] bg-card"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
name: d.knowledgePointName,
errorRate: Number((d.errorRate * 100).toFixed(0)),
totalAnswers: d.totalAnswers,
wrongAnswers: d.wrongAnswers,
}))
const chartConfig: ChartConfig = {
errorRate: {
label: t("teacher.knowledgePointWeakness.errorRate"),
color: "var(--color-chart-1)",
},
}
return (
<Card className={cn("overflow-hidden shadow-none", className)}>
<CardHeader>
<CardTitle className="text-base">{t("teacher.knowledgePointWeakness.title")}</CardTitle>
<CardDescription>{t("teacher.knowledgePointWeakness.description")}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/>
<YAxis
tickLine={false}
axisLine={false}
width={40}
tickFormatter={(value: number) => `${value}%`}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorRate: number
totalAnswers: number
wrongAnswers: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.totalAnswers")}
<span className="font-medium text-foreground">{p.totalAnswers}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.wrongAnswers")}
<span className="font-medium text-rose-600">{p.wrongAnswers}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.errorRate")}
<span className="font-medium text-rose-600">{p.errorRate}%</span>
</div>
</div>
)
}}
/>
}
/>
<Bar dataKey="errorRate" radius={[4, 4, 0, 0]}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,88 @@
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Progress } from "@/shared/components/ui/progress"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { cn } from "@/shared/lib/utils"
import type { TeacherClassPracticeOverview } from "@/modules/adaptive-practice/data-access-analytics"
interface ClassPracticeComparisonTableProps {
data: TeacherClassPracticeOverview[] | import("@/modules/adaptive-practice/data-access-analytics").GradeClassPracticeComparison[]
className?: string
}
/**
* 班级练习对比表格(教师/年级视图)
*
* 显示每个班级的参与率、练习数、完成数、答题数、正确率。
* 支持教师视图TeacherClassPracticeOverview和年级视图GradeClassPracticeComparison
*/
export function ClassPracticeComparisonTable({
data,
className,
}: ClassPracticeComparisonTableProps) {
const t = useTranslations("practice")
if (data.length === 0) return null
return (
<Card className={cn("shadow-none", className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("teacher.classComparison.title")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{data.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
{/* 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("teacher.classComparison.className")}</TableHead>
<TableHead className="text-right">{t("teacher.classComparison.totalStudents")}</TableHead>
<TableHead className="text-right">{t("teacher.classComparison.activeStudents")}</TableHead>
<TableHead>{t("teacher.classComparison.participationRate")}</TableHead>
<TableHead className="text-right">{t("teacher.classComparison.totalSessions")}</TableHead>
<TableHead className="text-right">{t("teacher.classComparison.completedSessions")}</TableHead>
<TableHead className="text-right">{t("teacher.classComparison.totalAnswered")}</TableHead>
<TableHead className="text-right">{t("teacher.classComparison.averageAccuracy")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => {
const participationPct = Math.round(row.participationRate * 100)
const accuracyPct = Math.round(row.averageAccuracy * 100)
return (
<TableRow key={row.classId}>
<TableCell className="font-medium">{row.className}</TableCell>
<TableCell className="text-right tabular-nums">{row.totalStudents}</TableCell>
<TableCell className="text-right tabular-nums">{row.activeStudents}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={participationPct} className="h-1.5 w-16" />
<span className="text-xs text-muted-foreground tabular-nums">{participationPct}%</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{row.totalSessions}</TableCell>
<TableCell className="text-right tabular-nums">{row.completedSessions}</TableCell>
<TableCell className="text-right tabular-nums">{row.totalQuestionsAnswered}</TableCell>
<TableCell className="text-right tabular-nums">
<span className={cn(accuracyPct >= 60 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400")}>
{accuracyPct}%
</span>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,64 @@
import { useTranslations } from "next-intl"
import { AlertTriangle, CheckCircle2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
interface InactiveStudentsAlertProps {
inactiveStudentIds: string[]
studentNames: Map<string, string>
className?: string
}
/**
* 未参与练习学生提醒卡片(教师视图)
*
* 当班级中有学生未参与任何专项练习时,显示提醒列表,
* 帮助教师识别需要主动引导的学生。
*/
export function InactiveStudentsAlert({
inactiveStudentIds,
studentNames,
className,
}: InactiveStudentsAlertProps) {
const t = useTranslations("practice")
const isEmpty = inactiveStudentIds.length === 0
return (
<Card className={cn("shadow-none", className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
{isEmpty ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
)}
{t("teacher.inactiveStudents.title")}
</CardTitle>
<CardDescription>{t("teacher.inactiveStudents.description")}</CardDescription>
</div>
<Badge variant={isEmpty ? "secondary" : "destructive"} className="tabular-nums">
{inactiveStudentIds.length}
</Badge>
</CardHeader>
<CardContent>
{isEmpty ? (
<div className="flex h-[120px] items-center justify-center text-sm text-muted-foreground">
{t("teacher.inactiveStudents.empty")}
</div>
) : (
<div className="flex flex-wrap gap-2">
{inactiveStudentIds.map((studentId) => (
<Badge key={studentId} variant="outline" className="text-xs">
{studentNames.get(studentId) ?? "Unknown"}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { Target, Clock, CheckCircle2, XCircle } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { formatDate } from "@/shared/lib/utils"
import type { PracticeSessionSummary, PracticeStatus } from "../types"
interface PracticeHistoryProps {
sessions: PracticeSessionSummary[]
}
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = {
in_progress: "default",
completed: "secondary",
abandoned: "outline",
}
/**
* 专项练习历史列表
*/
export function PracticeHistory({ sessions }: PracticeHistoryProps): React.ReactNode {
const t = useTranslations("practice")
if (sessions.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Target className="mx-auto mb-2 h-8 w-8 opacity-50" />
{t("history.empty")}
</CardContent>
</Card>
)
}
return (
<div className="space-y-3">
{sessions.map((session) => {
const accuracy = session.answeredQuestions > 0
? Math.round((session.correctCount / session.answeredQuestions) * 100)
: 0
const progress = session.totalQuestions > 0
? (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>
</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>
</div>
<Progress value={progress} className="mt-2 h-1" />
</CardContent>
</Card>
</Link>
)
})}
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { Activity, BookOpen, CheckCircle2, Target, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
interface PracticeOverviewStatsCardsProps {
totalClasses: number
totalSessions: number
totalAnswered: number
averageAccuracy: number
participationRate: number
className?: string
}
/**
* 专项练习概览统计卡片(教师视图)
*
* 5 个卡片:覆盖班级 / 练习总数 / 答题总数 / 平均正确率 / 参与率
*/
export function PracticeOverviewStatsCards({
totalClasses,
totalSessions,
totalAnswered,
averageAccuracy,
participationRate,
className,
}: PracticeOverviewStatsCardsProps) {
const t = useTranslations("practice")
const cards = [
{
label: t("teacher.overview.totalClasses"),
value: totalClasses,
sub: "",
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: t("teacher.overview.totalSessions"),
value: totalSessions,
sub: "",
icon: Activity,
color: "text-purple-600 dark:text-purple-400",
bg: "bg-purple-50 dark:bg-purple-950/30",
},
{
label: t("teacher.overview.totalAnswered"),
value: totalAnswered,
sub: "",
icon: Target,
color: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-50 dark:bg-amber-950/30",
},
{
label: t("teacher.overview.averageAccuracy"),
value: `${Math.round(averageAccuracy * 100)}%`,
sub: averageAccuracy >= 0.6 ? "" : "",
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-50 dark:bg-emerald-950/30",
},
{
label: t("teacher.overview.participationRate"),
value: `${Math.round(participationRate * 100)}%`,
sub: participationRate >= 0.5 ? "" : "",
icon: Users,
color: "text-rose-600 dark:text-rose-400",
bg: "bg-rose-50 dark:bg-rose-950/30",
},
]
return (
<div className={cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-5", className)}>
{cards.map((card) => {
const Icon = card.icon
return (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{card.label}</div>
<div className={cn("mt-1 text-2xl font-bold tabular-nums", card.color)}>
{card.value}
</div>
{card.sub ? <div className="mt-0.5 text-xs text-muted-foreground">{card.sub}</div> : null}
</div>
<div className={cn("rounded-lg p-2", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,550 @@
"use client"
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 { 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,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions"
import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types"
interface PracticeSessionViewProps {
session: PracticeSessionDetail
}
/**
* 专项练习答题界面
*
* 功能:
* 1. 逐题作答,支持上一题/下一题导航
* 2. 自动判分(选择题/判断题)
* 3. 跳过题目
* 4. 完成练习后展示结果摘要
*/
export function PracticeSessionView({ session }: PracticeSessionViewProps): React.ReactNode {
const t = useTranslations("practice")
const router = useRouter()
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 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],
)
const correctCount = useMemo(
() => answersList.filter((a) => a.isCorrect === true).length,
[answersList],
)
if (session.status !== "in_progress") {
return <PracticeResultView session={session} />
}
if (total === 0) {
return (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t("session.empty")}
</CardContent>
</Card>
)
}
function handleSubmit(answer: unknown, skip: boolean = false): void {
if (!current) return
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({
sessionId: session.id,
answerId: current.id,
answer,
skip,
}),
)
const res = await submitPracticeAnswerAction(undefined, formData)
if (res.success && res.data) {
setResults((prev) => ({ ...prev, [current.id]: res.data! }))
toast.success(res.message ?? t("toasts.submitted"))
// 自动跳到下一题
if (currentIndex < total - 1) {
setCurrentIndex(currentIndex + 1)
}
} else {
toast.error(res.message ?? t("toasts.submitFailed"))
}
})
}
function handleComplete(): void {
startTransition(async () => {
const formData = new FormData()
formData.append("sessionId", session.id)
const res = await completePracticeSessionAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? t("toasts.completed"))
router.refresh()
} else {
toast.error(res.message ?? t("toasts.completeFailed"))
}
})
}
function handleAbandon(): void {
startTransition(async () => {
const formData = new FormData()
formData.append("sessionId", session.id)
const res = await abandonPracticeSessionAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? t("toasts.abandoned"))
router.push("/student/practice")
} else {
toast.error(res.message ?? t("toasts.abandonFailed"))
}
})
}
const currentResult = current ? results[current.id] : undefined
const isAnswered = current?.status === "answered" || current?.status === "skipped"
return (
<div className="space-y-6">
{/* 顶部进度条 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
{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" />
{correctCount}
</span>
<span>{answeredCount}/{total}</span>
</div>
</div>
<Progress value={progress} className="mt-2" />
</CardHeader>
</Card>
{/* 题目内容 */}
{current ? (
<QuestionCard
answer={current}
index={currentIndex}
total={total}
userAnswer={answers[current.id]}
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
onSubmit={(ans) => handleSubmit(ans)}
onSkip={() => handleSubmit(null, true)}
isAnswered={isAnswered}
result={currentResult}
isPending={isPending}
/>
) : null}
{/* 底部导航 */}
<div className="flex items-center justify-between">
<Button
variant="outline"
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
disabled={currentIndex === 0}
>
<ChevronLeft className="h-4 w-4" />
{t("session.previous")}
</Button>
<div className="flex gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Flag className="h-4 w-4" />
{t("session.abandon")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("session.abandonConfirm")}</AlertDialogTitle>
<AlertDialogDescription>{t("session.abandonDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleAbandon} disabled={isPending}>
{t("session.confirmAbandon")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{currentIndex < total - 1 ? (
<Button
variant="outline"
onClick={() => setCurrentIndex(currentIndex + 1)}
>
{t("session.next")}
<ChevronRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={handleComplete} disabled={isPending || answeredCount < total}>
<Trophy className="h-4 w-4" />
{t("session.complete")}
</Button>
)}
</div>
</div>
</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,263 @@
"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 { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { createPracticeSessionAction } from "../actions"
import type { PracticeType } from "../types"
interface PracticeStarterProps {
/** 知识点选项列表 */
knowledgePoints: Array<{ id: string; name: string }>
/** 预设模式(从错题本发起时传入) */
presetMode?: {
type: PracticeType
sourceMeta: Record<string, unknown>
subjectId?: string
}
/** 是否禁用类型选择(预设模式时) */
lockType?: boolean
}
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
/**
* 专项练习发起器
*
* 支持四种练习模式:
* 1. 错题变式练习:从错题本发起
* 2. 知识点专项:选择知识点后抽题
* 3. 薄弱章节:自动识别薄弱知识点
* 4. AI 推荐AI 根据学情推荐
*/
export function PracticeStarter({ knowledgePoints, presetMode, lockType }: PracticeStarterProps): React.ReactNode {
const t = useTranslations("practice")
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [practiceType, setPracticeType] = useState<PracticeType>(
presetMode?.type ?? "knowledge_point",
)
const [selectedKpIds, setSelectedKpIds] = useState<string[]>([])
const [questionCount, setQuestionCount] = useState<number>(10)
const [difficulty, setDifficulty] = useState<number>(0)
function toggleKp(kpId: string): void {
setSelectedKpIds((prev) =>
prev.includes(kpId) ? prev.filter((id) => id !== kpId) : [...prev, kpId],
)
}
function handleStart(): void {
if (presetMode) {
// 预设模式:直接使用传入的 sourceMeta
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({
practiceType: presetMode.type,
subjectId: presetMode.subjectId,
sourceMeta: presetMode.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"))
}
})
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") {
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
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"
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
{t("starter.title")}
</CardTitle>
<CardDescription>{t("starter.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 练习类型选择 */}
{!lockType ? (
<div className="space-y-2">
<Label>{t("starter.type")}</Label>
<Select
value={practiceType}
onValueChange={(v: string) => setPracticeType(v as PracticeType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="knowledge_point">
<span className="flex items-center gap-2">
<BookOpen className="h-4 w-4" />
{t("types.knowledge_point")}
</span>
</SelectItem>
<SelectItem value="weak_chapter">
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{t("types.weak_chapter")}
</span>
</SelectItem>
<SelectItem value="ai_recommended">
<span className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{t("types.ai_recommended")}
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{/* 知识点选择 */}
{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>
</div>
) : null}
{/* 难度选择(仅知识点专项) */}
{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>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("starter.anyDifficulty")}</SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="5">5</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{/* 题目数量 */}
<div className="space-y-2">
<Label>{t("starter.questionCount")}</Label>
<Select
value={String(questionCount)}
onValueChange={(v: string) => setQuestionCount(Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{QUESTION_COUNT_OPTIONS.map((count) => (
<SelectItem key={count} value={String(count)}>
{count}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 开始按钮 */}
<Button
onClick={handleStart}
disabled={isPending}
className="w-full"
>
{isPending ? t("starter.creating") : t("starter.start")}
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,73 @@
"use client"
import { useTranslations } from "next-intl"
import { Target, CheckCircle2, TrendingUp, Award } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
import type { PracticeStats } from "../types"
interface PracticeStatsCardsProps {
stats: PracticeStats
}
/**
* 专项练习统计卡片
*/
export function PracticeStatsCards({ stats }: PracticeStatsCardsProps): React.ReactNode {
const t = useTranslations("practice")
const accuracyPercent = Math.round(stats.overallAccuracy * 100)
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.totalSessions")}</p>
<p className="text-2xl font-bold">{stats.totalSessions}</p>
</div>
<Target className="h-8 w-8 text-primary opacity-80" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.completed")}</p>
<p className="text-2xl font-bold">{stats.completedSessions}</p>
</div>
<CheckCircle2 className="h-8 w-8 text-emerald-500 opacity-80" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.totalAnswered")}</p>
<p className="text-2xl font-bold">{stats.totalQuestionsAnswered}</p>
</div>
<Award className="h-8 w-8 text-amber-500 opacity-80" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.accuracy")}</p>
<p className="text-2xl font-bold">{accuracyPercent}%</p>
</div>
<TrendingUp className="h-8 w-8 text-blue-500 opacity-80" />
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/ui/chart"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { PracticeTypeBreakdown } from "@/modules/adaptive-practice/data-access-analytics"
interface PracticeTypeBreakdownChartProps {
data: PracticeTypeBreakdown[]
className?: string
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
]
/**
* 练习类型分布柱状图(教师视图)
*
* 横轴:练习类型,纵轴:会话数
* tooltip 显示答题数、正确数、正确率
*/
export function PracticeTypeBreakdownChart({
data,
className,
}: PracticeTypeBreakdownChartProps) {
const t = useTranslations("practice")
if (data.length === 0) return null
const chartData = data.map((d) => ({
name: t(`types.${d.practiceType}` as const),
sessionCount: d.sessionCount,
totalQuestions: d.totalQuestions,
correctCount: d.correctCount,
accuracy: Number((d.accuracy * 100).toFixed(0)),
}))
const chartConfig: ChartConfig = {
sessionCount: {
label: t("teacher.classComparison.totalSessions"),
color: "var(--color-chart-1)",
},
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base">{t("teacher.typeBreakdown.title")}</CardTitle>
<CardDescription>{t("teacher.typeBreakdown.description")}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} width={36} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
sessionCount: number
totalQuestions: number
correctCount: number
accuracy: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.totalSessions")}
<span className="font-medium text-foreground">{p.sessionCount}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.totalAnswered")}
<span className="font-medium text-foreground">{p.totalQuestions}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.wrongAnswers")}
<span className="font-medium text-rose-600">{p.totalQuestions - p.correctCount}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.averageAccuracy")}
<span className="font-medium text-emerald-600">{p.accuracy}%</span>
</div>
</div>
)
}}
/>
}
/>
<Bar dataKey="sessionCount" radius={[4, 4, 0, 0]}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,112 @@
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Progress } from "@/shared/components/ui/progress"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { cn, formatDate } from "@/shared/lib/utils"
import type { StudentPracticeSummary } from "@/modules/adaptive-practice/data-access-analytics"
interface StudentPracticeRankingTableProps {
data: StudentPracticeSummary[]
studentNames: Map<string, string>
className?: string
}
/**
* 学生练习排名表格(教师视图)
*
* 按练习数降序排列,显示每个学生的练习数、完成数、答题数、正确率、最近练习时间。
* 用于识别活跃学生与待引导学生。
*/
export function StudentPracticeRankingTable({
data,
studentNames,
className,
}: StudentPracticeRankingTableProps) {
const t = useTranslations("practice")
if (data.length === 0) return null
// 按练习数降序排列
const sorted = [...data].sort((a, b) => b.totalSessions - a.totalSessions)
return (
<Card className={cn("shadow-none", className)}>
<CardHeader>
<CardTitle className="text-base">{t("teacher.studentRanking.title")}</CardTitle>
<CardDescription>{t("teacher.studentRanking.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
{/* 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-12">{t("teacher.studentRanking.rank")}</TableHead>
<TableHead>{t("teacher.studentRanking.student")}</TableHead>
<TableHead className="text-right">{t("teacher.studentRanking.totalSessions")}</TableHead>
<TableHead className="text-right">{t("teacher.studentRanking.completedSessions")}</TableHead>
<TableHead className="text-right">{t("teacher.studentRanking.totalAnswered")}</TableHead>
<TableHead>{t("teacher.studentRanking.accuracy")}</TableHead>
<TableHead className="text-right">{t("teacher.studentRanking.lastPractice")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((student, idx) => {
const name = studentNames.get(student.studentId) ?? "Unknown"
const accuracyPct = Math.round(student.accuracy * 100)
const hasPractice = student.totalSessions > 0
return (
<TableRow
key={student.studentId}
className={cn(!hasPractice && "opacity-50")}
>
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell className="font-medium">{name}</TableCell>
<TableCell className="text-right tabular-nums">
{student.totalSessions > 0 ? (
<Badge variant="secondary" className="tabular-nums">
{student.totalSessions}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{student.completedSessions}</TableCell>
<TableCell className="text-right tabular-nums">{student.totalQuestionsAnswered}</TableCell>
<TableCell>
{hasPractice ? (
<div className="flex items-center gap-2">
<Progress value={accuracyPct} className="h-1.5 w-16" />
<span
className={cn(
"text-xs tabular-nums",
accuracyPct >= 60
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400",
)}
>
{accuracyPct}%
</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{student.lastPracticeAt ? formatDate(student.lastPracticeAt) : "-"}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,634 @@
import "server-only"
import { cache } from "react"
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 { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
// ---------------------------------------------------------------------------
// 类型定义
// ---------------------------------------------------------------------------
/** 班级专项练习统计 */
export interface ClassPracticeStats {
classId: string
className: string
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
averageAccuracy: number
/** 参与练习的学生数 */
activeStudents: number
}
/** 学生专项练习摘要 */
export interface StudentPracticeSummary {
studentId: string
studentName: string | null
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
accuracy: number
lastPracticeAt: Date | null
}
/** 按练习类型分组的统计 */
export interface PracticeTypeBreakdown {
practiceType: string
sessionCount: number
totalQuestions: number
correctCount: number
accuracy: number
}
// ---------------------------------------------------------------------------
// 查询函数
// ---------------------------------------------------------------------------
/**
* 获取班级专项练习统计。
*
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
*
* @param classId 班级 ID
*/
export const getClassPracticeStats = cache(async (
classId: string,
): Promise<ClassPracticeStats | null> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return null
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)`,
})
.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: "", // 由调用方填充
totalSessions: Number(row.totalSessions),
completedSessions: Number(row.completedSessions),
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents,
}
})
/**
* 获取班级所有学生的专项练习摘要。
*
* @param classId 班级 ID
*/
export const getClassStudentPracticeSummaries = cache(async (
classId: string,
): Promise<StudentPracticeSummary[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return []
const rows = await db
.select({
studentId: practiceSessions.studentId,
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)`,
lastPracticeAt: sql<Date | null>`MAX(${practiceSessions.startedAt})`,
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
.groupBy(practiceSessions.studentId)
.orderBy(desc(sql`MAX(${practiceSessions.startedAt})`))
return rows.map((r) => ({
studentId: r.studentId,
studentName: null, // 由调用方填充
totalSessions: Number(r.totalSessions),
completedSessions: Number(r.completedSessions),
totalQuestionsAnswered: Number(r.totalQuestionsAnswered),
totalCorrect: Number(r.totalCorrect),
accuracy: Number(r.totalQuestionsAnswered) > 0
? Number(r.totalCorrect) / Number(r.totalQuestionsAnswered)
: 0,
lastPracticeAt: r.lastPracticeAt,
}))
})
/**
* 获取年级专项练习统计。
*
* @param gradeId 年级 ID
*/
export const getGradePracticeStats = cache(async (
gradeId: string,
): Promise<{
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
averageAccuracy: number
activeStudents: number
} | null> => {
const studentIds = await getUserIdsByGradeId(gradeId)
if (studentIds.length === 0) return null
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)`,
})
.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))
return {
totalSessions: Number(row.totalSessions),
completedSessions: Number(row.completedSessions),
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents: Number(activeStudentsResult[0]?.count ?? 0),
}
})
/**
* 获取按练习类型分组的统计(班级或年级维度)。
*
* @param studentIds 学生 ID 列表
*/
export const getPracticeTypeBreakdown = cache(async (
studentIds: string[],
): Promise<PracticeTypeBreakdown[]> => {
if (studentIds.length === 0) return []
const rows = await db
.select({
practiceType: practiceSessions.practiceType,
sessionCount: count(),
totalQuestions: sql<number>`COALESCE(SUM(${practiceSessions.totalQuestions}), 0)`,
correctCount: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
.groupBy(practiceSessions.practiceType)
return rows.map((r) => ({
practiceType: r.practiceType,
sessionCount: Number(r.sessionCount),
totalQuestions: Number(r.totalQuestions),
correctCount: Number(r.correctCount),
accuracy: Number(r.totalQuestions) > 0
? Number(r.correctCount) / Number(r.totalQuestions)
: 0,
}))
})
/**
* 获取班级中未参与专项练习的学生列表。
*
* 用于教师识别"从未使用专项练习功能"的学生,
* 这些学生可能缺乏自主学习意识,需要教师引导。
*
* @param classId 班级 ID
*/
export const getStudentsWithoutPractice = cache(async (
classId: string,
): Promise<string[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return []
// 查询有练习记录的学生
const activeRows = await db
.select({ studentId: practiceSessions.studentId })
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
.groupBy(practiceSessions.studentId)
const activeSet = new Set(activeRows.map((r) => r.studentId))
// 返回没有练习记录的学生
return studentIds.filter((id) => !activeSet.has(id))
})
// ---------------------------------------------------------------------------
// 教师视图:所教班级练习概览
// ---------------------------------------------------------------------------
/** 教师所教班级的练习概览(每个班级一行) */
export interface TeacherClassPracticeOverview {
classId: string
className: string
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
averageAccuracy: number
activeStudents: number
/** 该班级学生总数 */
totalStudents: number
/** 参与率 0-1 */
participationRate: number
}
/**
* 获取教师所教所有班级的专项练习概览。
*
* 用于教师端分析页面顶部展示每个班级的练习情况。
*
* @param classIds 教师/年级主任可访问的班级 ID 列表
*/
export const getTeacherClassPracticeOverviews = cache(async (
classIds: string[],
): 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)
const totalStudents = studentIds.length
if (totalStudents === 0) {
return {
classId,
className: className ?? "",
totalSessions: 0,
completedSessions: 0,
totalQuestionsAnswered: 0,
totalCorrect: 0,
averageAccuracy: 0,
activeStudents: 0,
totalStudents: 0,
participationRate: 0,
}
}
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)`,
})
.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,
}
}),
)
return results.filter((r): r is TeacherClassPracticeOverview => r !== null)
})
// ---------------------------------------------------------------------------
// 班级知识点薄弱度(基于练习答题数据)
// ---------------------------------------------------------------------------
/** 班级知识点薄弱度 */
export interface ClassKnowledgePointWeakness {
knowledgePointId: string
knowledgePointName: string
/** 该知识点总答题数 */
totalAnswers: number
/** 该知识点答错数 */
wrongAnswers: number
/** 错误率 0-1 */
errorRate: number
}
/**
* 获取班级在专项练习中暴露的知识点薄弱度。
*
* 通过分析 practiceAnswers 表中 isCorrect = false 的记录,
* 关联题目到知识点,统计每个知识点的错误率。
*
* @param classId 班级 ID
* @param limit 返回条数(默认 10
*/
export const getClassKnowledgePointWeakness = cache(async (
classId: string,
limit = 10,
): Promise<ClassKnowledgePointWeakness[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return []
// 查询该班级学生在练习中的答题记录(联表题目→知识点)
const rows = await db
.select({
knowledgePointId: knowledgePoints.id,
knowledgePointName: knowledgePoints.name,
totalAnswers: count(),
wrongAnswers: count(sql`CASE WHEN ${practiceAnswers.isCorrect} = false THEN 1 END`),
})
.from(practiceAnswers)
.innerJoin(questions, eq(questions.id, practiceAnswers.questionId))
.innerJoin(questionsToKnowledgePoints, eq(questionsToKnowledgePoints.questionId, questions.id))
.innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId))
.where(
and(
inArray(practiceAnswers.studentId, studentIds),
eq(practiceAnswers.status, "answered"),
),
)
.groupBy(knowledgePoints.id, knowledgePoints.name)
.orderBy(desc(count()))
.limit(limit)
return rows.map((r) => {
const totalAnswers = Number(r.totalAnswers)
const wrongAnswers = Number(r.wrongAnswers)
return {
knowledgePointId: r.knowledgePointId,
knowledgePointName: r.knowledgePointName,
totalAnswers,
wrongAnswers,
errorRate: totalAnswers > 0 ? wrongAnswers / totalAnswers : 0,
}
})
})
// ---------------------------------------------------------------------------
// 年级视图:各班级练习对比
// ---------------------------------------------------------------------------
/** 年级各班级练习对比 */
export interface GradeClassPracticeComparison {
classId: string
className: string
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
averageAccuracy: number
activeStudents: number
totalStudents: number
participationRate: number
}
/**
* 获取年级各班级专项练习对比数据。
*
* @param gradeId 年级 ID
*/
export const getGradeClassPracticeComparison = cache(async (
gradeId: string,
): Promise<GradeClassPracticeComparison[]> => {
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
if (totalStudents === 0) {
return {
classId: cls.id,
className: cls.name,
totalSessions: 0,
completedSessions: 0,
totalQuestionsAnswered: 0,
totalCorrect: 0,
averageAccuracy: 0,
activeStudents: 0,
totalStudents: 0,
participationRate: 0,
}
}
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)`,
})
.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,
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,
}
}),
)
// 按参与率降序排列
return results.sort((a, b) => b.participationRate - a.participationRate)
})
// ---------------------------------------------------------------------------
// 综合宏观数据分析:跨模块整合(错题 + 掌握度 + 练习)
// ---------------------------------------------------------------------------
/** 班级综合学习画像(整合错题、练习、掌握度) */
export interface ClassLearningProfile {
classId: string
className: string
totalStudents: number
activeStudents: number
participationRate: number
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
averageAccuracy: number
/** 未参与练习的学生 ID 列表 */
inactiveStudentIds: string[]
/** 知识点薄弱度 Top N */
weakKnowledgePoints: ClassKnowledgePointWeakness[]
}
/**
* 获取班级综合学习画像。
*
* 整合专项练习数据、知识点薄弱度、未参与学生识别,
* 为班主任提供宏观决策依据。
*
* @param classId 班级 ID
* @param weakKpLimit 知识点薄弱度返回条数(默认 5
*/
export const getClassLearningProfile = cache(async (
classId: string,
weakKpLimit = 5,
): Promise<ClassLearningProfile | null> => {
const [className, studentIds, stats, weakKps, inactiveIds] = await Promise.all([
getClassNameById(classId),
getActiveStudentIdsByClassId(classId),
getClassPracticeStats(classId),
getClassKnowledgePointWeakness(classId, weakKpLimit),
getStudentsWithoutPractice(classId),
])
if (studentIds.length === 0) return null
return {
classId,
className: className ?? "",
totalStudents: studentIds.length,
activeStudents: stats?.activeStudents ?? 0,
participationRate: studentIds.length > 0
? (stats?.activeStudents ?? 0) / studentIds.length
: 0,
totalSessions: stats?.totalSessions ?? 0,
completedSessions: stats?.completedSessions ?? 0,
totalQuestionsAnswered: stats?.totalQuestionsAnswered ?? 0,
totalCorrect: stats?.totalCorrect ?? 0,
averageAccuracy: stats?.averageAccuracy ?? 0,
inactiveStudentIds: inactiveIds,
weakKnowledgePoints: weakKps,
}
})
/**
* 批量获取学生姓名映射。
*
* 跨模块封装 users/data-access 的 getUserNamesByIds
* 返回简化的 Map<studentId, name> 供分析页面使用。
*
* @param studentIds 学生 ID 列表
*/
export const getStudentNameMap = cache(async (
studentIds: string[],
): Promise<Map<string, string>> => {
if (studentIds.length === 0) return new Map()
const nameMap = await getUserNamesByIds(studentIds)
const result = new Map<string, string>()
for (const [id, info] of nameMap.entries()) {
result.set(id, info.name ?? "Unknown")
}
return result
})
/**
* 获取年级所有班级的练习统计概览。
*
* 用于年级主任宏观数据分析页面,一次性获取年级所有班级的练习数据。
*
* @param gradeId 年级 ID
*/
export const getGradeClassOverviews = cache(async (
gradeId: string,
): Promise<GradeClassPracticeComparison[]> => {
return getGradeClassPracticeComparison(gradeId)
})
/**
* 获取年级综合练习统计(聚合所有班级)。
*
* @param gradeId 年级 ID
*/
export const getGradePracticeOverview = cache(async (
gradeId: string,
): Promise<{
totalClasses: number
totalStudents: number
activeStudents: number
participationRate: number
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
averageAccuracy: number
} | null> => {
const classComparisons = await getGradeClassPracticeComparison(gradeId)
if (classComparisons.length === 0) return null
const totalClasses = classComparisons.length
const totalStudents = classComparisons.reduce((sum, c) => sum + c.totalStudents, 0)
const activeStudents = classComparisons.reduce((sum, c) => sum + c.activeStudents, 0)
const totalSessions = classComparisons.reduce((sum, c) => sum + c.totalSessions, 0)
const completedSessions = classComparisons.reduce((sum, c) => sum + c.completedSessions, 0)
const totalQuestionsAnswered = classComparisons.reduce((sum, c) => sum + c.totalQuestionsAnswered, 0)
const totalCorrect = classComparisons.reduce((sum, c) => sum + c.totalCorrect, 0)
return {
totalClasses,
totalStudents,
activeStudents,
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
totalSessions,
completedSessions,
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
}
})

View File

@@ -0,0 +1,343 @@
import "server-only"
import { and, eq, inArray, notInArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
knowledgePointMastery,
practiceAnswers,
questions,
questionsToKnowledgePoints,
} from "@/shared/db/schema"
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
// ---------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------
/** 默认出题数量 */
const DEFAULT_QUESTION_COUNT = 10
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
const WEAK_MASTERY_THRESHOLD = 60
// ---------------------------------------------------------------------------
// 出题策略
// ---------------------------------------------------------------------------
/**
* 错题变式练习出题策略。
*
* 从错题本中选取错题,直接使用原题进行练习(不依赖 AI 生成变式题,
* 确保即使 AI 不可用也能练习)。
*
* 策略:
* 1. 从 sourceQuestionIds 中查询题库中存在的题目
* 2. 排除已在该练习会话中的题目(由调用方保证)
* 3. 按难度升序排列(先易后难,建立信心)
*/
async function selectForErrorVariant(
sourceMeta: PracticeSourceMeta,
questionCount: number,
): Promise<QuestionSelectionResult> {
if (!isErrorVariantSourceMeta(sourceMeta)) {
return { questionIds: [], variants: new Map() }
}
const sourceQuestionIds = sourceMeta.sourceQuestionIds
if (sourceQuestionIds.length === 0) {
return { questionIds: [], variants: new Map() }
}
const rows = await db
.select({
id: questions.id,
difficulty: questions.difficulty,
})
.from(questions)
.where(and(
inArray(questions.id, sourceQuestionIds),
sql`${questions.parentId} IS NULL`,
))
.orderBy(questions.difficulty)
.limit(questionCount)
return {
questionIds: rows.map((r) => r.id),
variants: new Map(),
}
}
/**
* 知识点专项练习出题策略。
*
* 从题库中按知识点筛选题目,支持难度过滤。
*
* 策略:
* 1. 按知识点 ID 查询关联题目
* 2. 按难度筛选(如指定)
* 3. 随机抽取指定数量
*/
async function selectForKnowledgePoint(
sourceMeta: PracticeSourceMeta,
questionCount: number,
): Promise<QuestionSelectionResult> {
if (!isKnowledgePointSourceMeta(sourceMeta)) {
return { questionIds: [], variants: new Map() }
}
const { knowledgePointIds, difficulty } = sourceMeta
if (knowledgePointIds.length === 0) {
return { questionIds: [], variants: new Map() }
}
const conditions = [
inArray(questionsToKnowledgePoints.knowledgePointId, knowledgePointIds),
sql`${questions.parentId} IS NULL`,
]
if (difficulty !== undefined) {
conditions.push(eq(questions.difficulty, difficulty))
}
// 使用子查询获取题目 ID然后随机排序
const rows = await db
.select({
id: questions.id,
})
.from(questions)
.innerJoin(
questionsToKnowledgePoints,
eq(questionsToKnowledgePoints.questionId, questions.id),
)
.where(and(...conditions))
.groupBy(questions.id)
.orderBy(sql`RAND()`)
.limit(questionCount)
return {
questionIds: rows.map((r) => r.id),
variants: new Map(),
}
}
/**
* 薄弱章节练习出题策略。
*
* 根据学生掌握度自动识别薄弱知识点,从这些知识点中抽题。
*
* 策略:
* 1. 查询学生在指定章节知识点上的掌握度
* 2. 筛选掌握度低于阈值的知识点
* 3. 从薄弱知识点中抽题
*/
async function selectForWeakChapter(
studentId: string,
sourceMeta: PracticeSourceMeta,
questionCount: number,
): Promise<QuestionSelectionResult> {
if (!isWeakChapterSourceMeta(sourceMeta)) {
return { questionIds: [], variants: new Map() }
}
const { weakKnowledgePointIds } = sourceMeta
if (weakKnowledgePointIds.length === 0) {
return { questionIds: [], variants: new Map() }
}
// 查询学生已做过的题目(避免重复)
const answeredQuestionIds = await getStudentAnsweredQuestionIds(studentId)
// 从薄弱知识点中抽题
const conditions = [
inArray(questionsToKnowledgePoints.knowledgePointId, weakKnowledgePointIds),
sql`${questions.parentId} IS NULL`,
]
if (answeredQuestionIds.length > 0) {
conditions.push(notInArray(questions.id, answeredQuestionIds))
}
const rows = await db
.select({
id: questions.id,
})
.from(questions)
.innerJoin(
questionsToKnowledgePoints,
eq(questionsToKnowledgePoints.questionId, questions.id),
)
.where(and(...conditions))
.groupBy(questions.id)
.orderBy(sql`RAND()`)
.limit(questionCount)
return {
questionIds: rows.map((r) => r.id),
variants: new Map(),
}
}
/**
* AI 推荐练习出题策略。
*
* AI 推荐的知识点列表由上层AI 分析)提供,
* 此函数仅负责从推荐知识点中抽题。
*/
async function selectForAiRecommended(
studentId: string,
sourceMeta: PracticeSourceMeta,
questionCount: number,
): Promise<QuestionSelectionResult> {
if (!isAiRecommendedSourceMeta(sourceMeta)) {
return { questionIds: [], variants: new Map() }
}
const { recommendedKnowledgePointIds } = sourceMeta
if (recommendedKnowledgePointIds.length === 0) {
return { questionIds: [], variants: new Map() }
}
// 查询学生已做过的题目
const answeredQuestionIds = await getStudentAnsweredQuestionIds(studentId)
const conditions = [
inArray(questionsToKnowledgePoints.knowledgePointId, recommendedKnowledgePointIds),
sql`${questions.parentId} IS NULL`,
]
if (answeredQuestionIds.length > 0) {
conditions.push(notInArray(questions.id, answeredQuestionIds))
}
const rows = await db
.select({
id: questions.id,
})
.from(questions)
.innerJoin(
questionsToKnowledgePoints,
eq(questionsToKnowledgePoints.questionId, questions.id),
)
.where(and(...conditions))
.groupBy(questions.id)
.orderBy(sql`RAND()`)
.limit(questionCount)
return {
questionIds: rows.map((r) => r.id),
variants: new Map(),
}
}
// ---------------------------------------------------------------------------
// 主入口
// ---------------------------------------------------------------------------
/**
* 根据练习类型选择题目。
*
* @param studentId 学生 ID
* @param practiceType 练习类型
* @param sourceMeta 来源元数据
* @param questionCount 题目数量
* @returns 选中的题目 ID 列表和变式题映射
*/
export async function selectQuestionsForPractice(
studentId: string,
practiceType: string,
sourceMeta: PracticeSourceMeta,
questionCount: number = DEFAULT_QUESTION_COUNT,
): Promise<QuestionSelectionResult> {
switch (practiceType) {
case "error_variant":
return selectForErrorVariant(sourceMeta, questionCount)
case "knowledge_point":
return selectForKnowledgePoint(sourceMeta, questionCount)
case "weak_chapter":
return selectForWeakChapter(studentId, sourceMeta, questionCount)
case "ai_recommended":
return selectForAiRecommended(studentId, sourceMeta, questionCount)
default:
return { questionIds: [], variants: new Map() }
}
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/**
* 获取学生已答过的题目 ID 列表(避免重复练习)。
*
* 从专项练习中汇总已答题目。
* 为控制查询量,仅查询最近 1000 条记录。
*/
async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
const rows = await db
.select({ questionId: practiceAnswers.questionId })
.from(practiceAnswers)
.where(eq(practiceAnswers.studentId, studentId))
.limit(1000)
return Array.from(new Set(rows.map((r) => r.questionId)))
}
/**
* 自动识别学生的薄弱知识点。
*
* 查询掌握度低于阈值的知识点,按掌握度升序排列。
* 用于"薄弱章节练习"的自动推荐。
*
* @param studentId 学生 ID
* @param chapterId 章节 ID可选不传则查询所有章节
* @param limit 返回数量限制
* @returns 薄弱知识点 ID 列表
*/
export async function identifyWeakKnowledgePoints(
studentId: string,
chapterId?: string,
limit: number = 5,
): Promise<string[]> {
const conditions = [
eq(knowledgePointMastery.studentId, studentId),
sql`${knowledgePointMastery.masteryLevel} < ${WEAK_MASTERY_THRESHOLD}`,
]
const rows = await db
.select({
knowledgePointId: knowledgePointMastery.knowledgePointId,
masteryLevel: knowledgePointMastery.masteryLevel,
})
.from(knowledgePointMastery)
.where(and(...conditions))
.orderBy(knowledgePointMastery.masteryLevel)
.limit(limit)
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

@@ -0,0 +1,619 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
practiceAnswers,
practiceSessions,
questions,
} from "@/shared/db/schema"
import { selectQuestionsForPractice } from "./data-access-strategy"
import type {
PracticeAnswerRecord,
PracticeAnswerStatus,
PracticeSessionDetail,
PracticeSessionSummary,
PracticeSourceMeta,
PracticeStats,
PracticeStatus,
PracticeType,
} from "./types"
// ---------------------------------------------------------------------------
// 行映射
// ---------------------------------------------------------------------------
function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessionSummary {
const answeredQuestions = row.answeredQuestions
const correctCount = row.correctCount
return {
id: row.id,
studentId: row.studentId,
subjectId: row.subjectId,
practiceType: row.practiceType as PracticeType,
status: row.status as PracticeStatus,
totalQuestions: row.totalQuestions,
answeredQuestions,
correctCount,
accuracy: answeredQuestions > 0 ? correctCount / answeredQuestions : 0,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
}
}
function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
question?: typeof questions.$inferSelect | null
}): PracticeAnswerRecord {
return {
id: row.id,
sessionId: row.sessionId,
questionId: row.questionId,
variantContent: row.variantContent,
isVariant: row.isVariant,
orderIndex: row.orderIndex,
status: row.status as PracticeAnswerStatus,
studentAnswer: row.studentAnswer,
isCorrect: row.isCorrect,
score: row.score,
maxScore: row.maxScore,
answeredAt: row.answeredAt,
question: row.question
? {
id: row.question.id,
content: row.question.content,
type: row.question.type,
difficulty: row.question.difficulty,
}
: null,
}
}
// ---------------------------------------------------------------------------
// 查询:练习会话列表
// ---------------------------------------------------------------------------
export const getPracticeSessions = cache(async (
studentId: string,
options?: {
status?: PracticeStatus
practiceType?: PracticeType
page?: number
pageSize?: number
},
): Promise<{ data: PracticeSessionSummary[]; total: number }> => {
const page = options?.page ?? 1
const pageSize = options?.pageSize ?? 20
const offset = (page - 1) * pageSize
const conditions = [eq(practiceSessions.studentId, studentId)]
if (options?.status) {
conditions.push(eq(practiceSessions.status, options.status))
}
if (options?.practiceType) {
conditions.push(eq(practiceSessions.practiceType, options.practiceType))
}
const whereClause = and(...conditions)
const [totalResult] = await db
.select({ value: count() })
.from(practiceSessions)
.where(whereClause)
const total = Number(totalResult?.value ?? 0)
const rows = await db
.select()
.from(practiceSessions)
.where(whereClause)
.orderBy(desc(practiceSessions.createdAt))
.limit(pageSize)
.offset(offset)
return {
data: rows.map(mapSessionRow),
total,
}
})
// ---------------------------------------------------------------------------
// 查询:练习会话详情(含答题记录)
// ---------------------------------------------------------------------------
export const getPracticeSessionById = cache(async (
sessionId: string,
studentId: string,
): Promise<PracticeSessionDetail | null> => {
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) return null
const answers = await db
.select()
.from(practiceAnswers)
.where(eq(practiceAnswers.sessionId, sessionId))
.orderBy(practiceAnswers.orderIndex)
// 批量查询题目内容
const questionIds = answers.map((a) => a.questionId)
const questionMap = new Map<string, typeof questions.$inferSelect>()
if (questionIds.length > 0) {
const questionRows = await db
.select()
.from(questions)
.where(inArray(questions.id, questionIds))
for (const q of questionRows) {
questionMap.set(q.id, q)
}
}
const mappedAnswers: PracticeAnswerRecord[] = answers.map((a) => {
const question = questionMap.get(a.questionId) ?? null
return mapAnswerRow({ ...a, question })
})
const summary = mapSessionRow(session)
return {
...summary,
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
answers: mappedAnswers,
}
})
// ---------------------------------------------------------------------------
// 查询:练习统计
// ---------------------------------------------------------------------------
export const getPracticeStats = cache(async (studentId: string): Promise<PracticeStats> => {
const rows = await db
.select({
practiceType: practiceSessions.practiceType,
status: practiceSessions.status,
totalQuestions: practiceSessions.totalQuestions,
answeredQuestions: practiceSessions.answeredQuestions,
correctCount: practiceSessions.correctCount,
})
.from(practiceSessions)
.where(eq(practiceSessions.studentId, studentId))
const totalSessions = rows.length
let completedSessions = 0
let totalQuestionsAnswered = 0
let totalCorrect = 0
const byTypeMap = new Map<string, { sessionCount: number; totalQuestions: number; correctCount: number }>()
for (const row of rows) {
if (row.status === "completed") {
completedSessions++
}
totalQuestionsAnswered += row.answeredQuestions
totalCorrect += row.correctCount
const stat = byTypeMap.get(row.practiceType) ?? { sessionCount: 0, totalQuestions: 0, correctCount: 0 }
stat.sessionCount++
stat.totalQuestions += row.totalQuestions
stat.correctCount += row.correctCount
byTypeMap.set(row.practiceType, stat)
}
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
practiceType: type as PracticeType,
sessionCount: stat.sessionCount,
totalQuestions: stat.totalQuestions,
correctCount: stat.correctCount,
accuracy: stat.totalQuestions > 0 ? stat.correctCount / stat.totalQuestions : 0,
}))
return {
totalSessions,
completedSessions,
totalQuestionsAnswered,
totalCorrect,
overallAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
byType,
}
})
// ---------------------------------------------------------------------------
// 写入:创建练习会话
// ---------------------------------------------------------------------------
/**
* 创建练习会话。
*
* 1. 根据练习类型调用出题策略选择题目
* 2. 创建会话记录
* 3. 创建答题记录(初始状态为 pending
*
* @returns 会话 ID 和选中的题目数量
*/
export async function createPracticeSession(
studentId: string,
input: {
practiceType: PracticeType
subjectId?: string
sourceMeta: PracticeSourceMeta
questionCount?: number
},
): Promise<{ sessionId: string; selectedCount: number }> {
const { practiceType, sourceMeta, questionCount = 10 } = input
// 调用出题策略选择题目
const selection = await selectQuestionsForPractice(
studentId,
practiceType,
sourceMeta,
questionCount,
)
if (selection.questionIds.length === 0) {
return { sessionId: "", selectedCount: 0 }
}
const sessionId = createId()
const now = new Date()
// 事务:创建会话 + 答题记录
await db.transaction(async (tx) => {
await tx.insert(practiceSessions).values({
id: sessionId,
studentId,
subjectId: input.subjectId ?? null,
practiceType,
sourceMeta: sourceMeta as unknown,
status: "in_progress",
totalQuestions: selection.questionIds.length,
answeredQuestions: 0,
correctCount: 0,
startedAt: now,
})
// 批量插入答题记录
const answerRows = selection.questionIds.map((questionId, index) => ({
id: createId(),
sessionId,
studentId,
questionId,
variantContent: selection.variants.get(questionId) ?? null,
isVariant: selection.variants.has(questionId),
orderIndex: index,
status: "pending" as const,
maxScore: 1,
}))
await tx.insert(practiceAnswers).values(answerRows)
})
return { sessionId, selectedCount: selection.questionIds.length }
}
// ---------------------------------------------------------------------------
// 写入:提交单题答案
// ---------------------------------------------------------------------------
/**
* 提交单题答案并自动判分。
*
* 自动判分逻辑:
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
* - 填空题暂不自动判分isCorrect = null
*
* @returns 是否判分成功
*/
export async function submitPracticeAnswer(
sessionId: string,
studentId: string,
answerId: string,
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),
),
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
}
if (session.status !== "in_progress") {
throw new Error("练习会话已结束")
}
// 查询答题记录
const answerRecord = await db.query.practiceAnswers.findFirst({
where: and(
eq(practiceAnswers.id, answerId),
eq(practiceAnswers.sessionId, sessionId),
),
})
if (!answerRecord) {
throw new Error("答题记录不存在")
}
if (answerRecord.status === "answered") {
throw new Error("此题已作答")
}
const now = new Date()
if (skip) {
// 跳过此题
await db
.update(practiceAnswers)
.set({
status: "skipped",
answeredAt: now,
})
.where(eq(practiceAnswers.id, answerId))
// 更新会话统计
await updateSessionStats(sessionId, 0, false)
return { isCorrect: null, score: null }
}
// 自动判分:查询题目内容并提取正确答案
const question = await db.query.questions.findFirst({
where: eq(questions.id, answerRecord.questionId),
})
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 }
}
// ---------------------------------------------------------------------------
// 写入:完成/放弃练习会话
// ---------------------------------------------------------------------------
export async function completePracticeSession(
sessionId: string,
studentId: string,
): Promise<void> {
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
}
if (session.status !== "in_progress") {
return
}
await db
.update(practiceSessions)
.set({
status: "completed",
completedAt: new Date(),
})
.where(eq(practiceSessions.id, sessionId))
}
export async function abandonPracticeSession(
sessionId: string,
studentId: string,
): Promise<void> {
const session = await db.query.practiceSessions.findFirst({
where: and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
}
if (session.status !== "in_progress") {
return
}
await db
.update(practiceSessions)
.set({
status: "abandoned",
completedAt: new Date(),
})
.where(eq(practiceSessions.id, sessionId))
}
// ---------------------------------------------------------------------------
// 内部辅助函数
// ---------------------------------------------------------------------------
/**
* 更新会话统计(已答题数、正确数)。
*/
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
}

View File

@@ -0,0 +1,74 @@
import { z } from "zod"
// ---------------------------------------------------------------------------
// 枚举验证
// ---------------------------------------------------------------------------
export const PracticeTypeSchema = z.enum([
"error_variant",
"knowledge_point",
"weak_chapter",
"ai_recommended",
])
export const PracticeAnswerStatusSchema = z.enum(["pending", "answered", "skipped"])
// ---------------------------------------------------------------------------
// 来源元数据验证
// ---------------------------------------------------------------------------
export const ErrorVariantSourceMetaSchema = z.object({
errorBookItemIds: z.array(z.string().min(1)).min(1),
sourceQuestionIds: z.array(z.string().min(1)).min(1),
})
export const KnowledgePointSourceMetaSchema = z.object({
knowledgePointIds: z.array(z.string().min(1)).min(1),
difficulty: z.number().int().min(1).max(5).optional(),
})
export const WeakChapterSourceMetaSchema = z.object({
chapterId: z.string().min(1),
weakKnowledgePointIds: z.array(z.string().min(1)).min(1),
})
export const AiRecommendedSourceMetaSchema = z.object({
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1),
reason: z.string().min(1),
})
// ---------------------------------------------------------------------------
// Action 输入验证
// ---------------------------------------------------------------------------
export const CreatePracticeSessionSchema = z.object({
practiceType: PracticeTypeSchema,
subjectId: z.string().min(1).optional(),
sourceMeta: z.record(z.string(), z.unknown()),
questionCount: z.number().int().min(1).max(50).default(10),
})
export const SubmitPracticeAnswerSchema = z.object({
sessionId: z.string().min(1),
answerId: z.string().min(1),
answer: z.unknown(),
/** 跳过此题 */
skip: z.boolean().optional(),
})
export const CompletePracticeSessionSchema = z.object({
sessionId: z.string().min(1),
})
export const AbandonPracticeSessionSchema = z.object({
sessionId: z.string().min(1),
})
// ---------------------------------------------------------------------------
// 类型导出
// ---------------------------------------------------------------------------
export type CreatePracticeSessionInput = z.infer<typeof CreatePracticeSessionSchema>
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema>

View File

@@ -0,0 +1,143 @@
/**
* Adaptive Practice 模块类型定义
*
* 专项练习闭环:错题变式 → 知识点专项 → 薄弱章节 → AI 推荐
*/
// ---------------------------------------------------------------------------
// 枚举类型(与 schema 保持一致)
// ---------------------------------------------------------------------------
export type PracticeType = "error_variant" | "knowledge_point" | "weak_chapter" | "ai_recommended"
export type PracticeStatus = "in_progress" | "completed" | "abandoned"
export type PracticeAnswerStatus = "pending" | "answered" | "skipped"
// ---------------------------------------------------------------------------
// 来源元数据
// ---------------------------------------------------------------------------
/** 错题变式练习来源 */
export interface ErrorVariantSourceMeta {
/** 触发练习的错题 ID 列表 */
errorBookItemIds: string[]
/** 关联的原题 ID 列表 */
sourceQuestionIds: string[]
}
/** 知识点专项练习来源 */
export interface KnowledgePointSourceMeta {
knowledgePointIds: string[]
/** 难度筛选1-5不传则不限 */
difficulty?: number
}
/** 薄弱章节练习来源 */
export interface WeakChapterSourceMeta {
chapterId: string
/** 自动识别的薄弱知识点 */
weakKnowledgePointIds: string[]
}
/** AI 推荐练习来源 */
export interface AiRecommendedSourceMeta {
/** AI 推荐的知识点列表 */
recommendedKnowledgePointIds: string[]
/** 推荐理由 */
reason: string
}
export type PracticeSourceMeta =
| ErrorVariantSourceMeta
| KnowledgePointSourceMeta
| WeakChapterSourceMeta
| AiRecommendedSourceMeta
// ---------------------------------------------------------------------------
// 数据传输类型
// ---------------------------------------------------------------------------
/** 练习会话(列表视图) */
export interface PracticeSessionSummary {
id: string
studentId: string
subjectId: string | null
practiceType: PracticeType
status: PracticeStatus
totalQuestions: number
answeredQuestions: number
correctCount: number
/** 正确率 0-1 */
accuracy: number
startedAt: Date
completedAt: Date | null
createdAt: Date
}
/** 练习答题记录 */
export interface PracticeAnswerRecord {
id: string
sessionId: string
questionId: string
variantContent: unknown | null
isVariant: boolean
orderIndex: number
status: PracticeAnswerStatus
studentAnswer: unknown | null
isCorrect: boolean | null
score: number | null
maxScore: number
answeredAt: Date | null
/** 关联题目内容(联表查询) */
question: {
id: string
content: unknown
type: string
difficulty: number | null
} | null
}
/** 练习会话详情(含答题记录) */
export interface PracticeSessionDetail extends PracticeSessionSummary {
sourceMeta: PracticeSourceMeta | null
answers: PracticeAnswerRecord[]
}
/** 练习统计 */
export interface PracticeStats {
totalSessions: number
completedSessions: number
totalQuestionsAnswered: number
totalCorrect: number
/** 总体正确率 0-1 */
overallAccuracy: number
/** 按练习类型分组统计 */
byType: Array<{
practiceType: PracticeType
sessionCount: number
totalQuestions: number
correctCount: number
accuracy: number
}>
}
// ---------------------------------------------------------------------------
// 出题策略输入
// ---------------------------------------------------------------------------
/** 创建练习会话的输入 */
export interface CreatePracticeSessionInput {
practiceType: PracticeType
subjectId?: string
/** 来源元数据(类型不同则结构不同) */
sourceMeta: PracticeSourceMeta
/** 题目数量(默认 10 */
questionCount?: number
}
/** 出题策略结果 */
export interface QuestionSelectionResult {
/** 选中的题目 ID 列表 */
questionIds: string[]
/** 变式题映射:原题 ID → 变式题内容AI 生成时使用) */
variants: Map<string, unknown>
}