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