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:
267
src/modules/adaptive-practice/actions.ts
Normal file
267
src/modules/adaptive-practice/actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
}))
|
||||
}
|
||||
263
src/modules/adaptive-practice/components/practice-starter.tsx
Normal file
263
src/modules/adaptive-practice/components/practice-starter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
634
src/modules/adaptive-practice/data-access-analytics.ts
Normal file
634
src/modules/adaptive-practice/data-access-analytics.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
343
src/modules/adaptive-practice/data-access-strategy.ts
Normal file
343
src/modules/adaptive-practice/data-access-strategy.ts
Normal 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
|
||||
}
|
||||
619
src/modules/adaptive-practice/data-access.ts
Normal file
619
src/modules/adaptive-practice/data-access.ts
Normal 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
|
||||
}
|
||||
74
src/modules/adaptive-practice/schema.ts
Normal file
74
src/modules/adaptive-practice/schema.ts
Normal 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>
|
||||
143
src/modules/adaptive-practice/types.ts
Normal file
143
src/modules/adaptive-practice/types.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user