feat(error-book): implement error book module with SM2 spaced repetition
- Add SM2 algorithm implementation with tests for spaced repetition review scheduling - Add data-access, schema, types, and server actions for error book CRUD - Add components: add dialog, class overview, filters, item card, stats cards, review buttons, top wrong questions - Add error-book routes for admin, teacher, parent, and student roles - Add i18n messages (en, zh-CN) for error book module
This commit is contained in:
341
src/modules/error-book/actions.ts
Normal file
341
src/modules/error-book/actions.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { z } from "zod"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import {
|
||||
CreateErrorBookItemSchema,
|
||||
ReviewErrorBookItemSchema,
|
||||
UpdateErrorBookNoteSchema,
|
||||
CollectFromSubmissionSchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
archiveErrorBookItem,
|
||||
collectFromExamSubmission,
|
||||
collectFromHomeworkSubmission,
|
||||
createErrorBookItem,
|
||||
deleteErrorBookItem,
|
||||
getErrorBookItemById,
|
||||
getErrorBookItems,
|
||||
getErrorBookStats,
|
||||
recordReview,
|
||||
updateErrorBookNote,
|
||||
} from "./data-access"
|
||||
import type { ErrorBookListResult, ErrorBookStats as ErrorBookStatsType, ErrorBookItemDetail } from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 学生端 Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getErrorBookItemsAction(
|
||||
params: {
|
||||
studentId?: string
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
sourceType?: string
|
||||
subjectId?: string
|
||||
dueOnly?: boolean
|
||||
}
|
||||
): Promise<ActionState<ErrorBookListResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
|
||||
// 学生只能看自己的错题本;家长查看子女的错题本(通过 dataScope.childrenIds 校验)
|
||||
const studentId = ctx.dataScope.type === "children"
|
||||
? params.studentId ?? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
// 如果传入 studentId 且不是自己,需要校验权限(家长查看子女)
|
||||
if (params.studentId && params.studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(params.studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ERROR_BOOK_READ)
|
||||
}
|
||||
}
|
||||
|
||||
const status = params.status && params.status !== "all"
|
||||
? (z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status).success
|
||||
? (params.status as "new" | "learning" | "mastered" | "archived")
|
||||
: undefined)
|
||||
: undefined
|
||||
|
||||
const sourceType = params.sourceType && params.sourceType !== "all"
|
||||
? (z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType).success
|
||||
? (params.sourceType as "exam" | "homework" | "manual")
|
||||
: undefined)
|
||||
: undefined
|
||||
|
||||
const data = await getErrorBookItems({
|
||||
studentId,
|
||||
q: params.q,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status,
|
||||
sourceType,
|
||||
subjectId: params.subjectId && params.subjectId !== "all" ? params.subjectId : undefined,
|
||||
dueOnly: params.dueOnly,
|
||||
})
|
||||
|
||||
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 getErrorBookItemDetailAction(
|
||||
itemId: string
|
||||
): Promise<ActionState<ErrorBookItemDetail>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
|
||||
const studentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
const data = await getErrorBookItemById(itemId, studentId)
|
||||
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 getErrorBookStatsAction(
|
||||
studentId?: string
|
||||
): Promise<ActionState<ErrorBookStatsType>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ERROR_BOOK_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
|
||||
const data = await getErrorBookStats(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 }
|
||||
}
|
||||
}
|
||||
|
||||
export async function createErrorBookItemAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE)
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误,需要 JSON 字段" }
|
||||
}
|
||||
|
||||
const parsed = CreateErrorBookItemSchema.safeParse(JSON.parse(jsonString))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const id = await createErrorBookItem(ctx.userId, parsed.data)
|
||||
revalidatePath("/student/error-book")
|
||||
|
||||
return { success: true, message: "错题已添加", data: id }
|
||||
} 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 updateErrorBookNoteAction(
|
||||
prevState: ActionState<void> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<void>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE)
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误" }
|
||||
}
|
||||
|
||||
const parsed = UpdateErrorBookNoteSchema.safeParse(JSON.parse(jsonString))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { itemId, ...input } = parsed.data
|
||||
await updateErrorBookNote(itemId, ctx.userId, input)
|
||||
|
||||
revalidatePath("/student/error-book")
|
||||
|
||||
return { success: true, message: "笔记已更新" }
|
||||
} 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 reviewErrorBookItemAction(
|
||||
prevState: ActionState<void> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<void>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE)
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误" }
|
||||
}
|
||||
|
||||
const parsed = ReviewErrorBookItemSchema.safeParse(JSON.parse(jsonString))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await recordReview(parsed.data.itemId, ctx.userId, parsed.data.result)
|
||||
|
||||
revalidatePath("/student/error-book")
|
||||
|
||||
return { success: true, message: "复习结果已记录" }
|
||||
} 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 deleteErrorBookItemAction(
|
||||
prevState: ActionState<void> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<void>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE)
|
||||
|
||||
const itemId = formData.get("itemId")
|
||||
if (typeof itemId !== "string") {
|
||||
return { success: false, message: "无效的错题 ID" }
|
||||
}
|
||||
|
||||
await deleteErrorBookItem(itemId, ctx.userId)
|
||||
|
||||
revalidatePath("/student/error-book")
|
||||
|
||||
return { success: true, message: "错题已删除" }
|
||||
} 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 archiveErrorBookItemAction(
|
||||
prevState: ActionState<void> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<void>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE)
|
||||
|
||||
const itemId = formData.get("itemId")
|
||||
if (typeof itemId !== "string") {
|
||||
return { success: false, message: "无效的错题 ID" }
|
||||
}
|
||||
|
||||
await archiveErrorBookItem(itemId, ctx.userId)
|
||||
|
||||
revalidatePath("/student/error-book")
|
||||
|
||||
return { success: true, message: "错题已归档" }
|
||||
} 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 collectFromSubmissionAction(
|
||||
prevState: ActionState<number> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_MANAGE)
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误" }
|
||||
}
|
||||
|
||||
const parsed = CollectFromSubmissionSchema.safeParse(JSON.parse(jsonString))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const collected = parsed.data.sourceType === "exam"
|
||||
? await collectFromExamSubmission(parsed.data.submissionId, ctx.userId)
|
||||
: await collectFromHomeworkSubmission(parsed.data.submissionId, ctx.userId)
|
||||
|
||||
revalidatePath("/student/error-book")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: collected > 0 ? `已采集 ${collected} 道错题` : "没有新的错题需要采集",
|
||||
data: collected,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "采集错题失败"
|
||||
return { success: false, message }
|
||||
}
|
||||
}
|
||||
177
src/modules/error-book/components/add-error-book-dialog.tsx
Normal file
177
src/modules/error-book/components/add-error-book-dialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition, useEffect } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { createErrorBookItemAction } from "../actions"
|
||||
import { COMMON_ERROR_TAGS } from "../types"
|
||||
|
||||
export function AddErrorBookDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [questionId, setQuestionId] = useState("")
|
||||
const [note, setNote] = useState("")
|
||||
const [errorTags, setErrorTags] = useState<string[]>([])
|
||||
const [questionOptions, setQuestionOptions] = useState<Array<{
|
||||
id: string
|
||||
preview: string
|
||||
}>>([])
|
||||
|
||||
function extractPreview(content: unknown): string {
|
||||
if (typeof content === "string") return content.slice(0, 60)
|
||||
if (Array.isArray(content)) {
|
||||
const texts: string[] = []
|
||||
for (const node of content) {
|
||||
if (typeof node === "string") texts.push(node)
|
||||
else if (typeof node === "object" && node !== null) {
|
||||
const n = node as Record<string, unknown>
|
||||
if (typeof n.text === "string") texts.push(n.text)
|
||||
}
|
||||
}
|
||||
return texts.join("").slice(0, 60)
|
||||
}
|
||||
return "题目"
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && questionOptions.length === 0) {
|
||||
getQuestionsAction({ pageSize: 100 })
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setQuestionOptions(
|
||||
res.data.data.map((q) => ({
|
||||
id: q.id,
|
||||
preview: extractPreview(q.content),
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [open, questionOptions.length])
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
setErrorTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!questionId) {
|
||||
toast.error("请选择题目")
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
"json",
|
||||
JSON.stringify({ questionId, note, errorTags })
|
||||
)
|
||||
const res = await createErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "已添加")
|
||||
setOpen(false)
|
||||
setQuestionId("")
|
||||
setNote("")
|
||||
setErrorTags([])
|
||||
} else {
|
||||
toast.error(res.message ?? "添加失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" data-icon="inline-start" />
|
||||
手动添加
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加错题</DialogTitle>
|
||||
<DialogDescription>
|
||||
从题库中选择题目,添加到你的错题本。你也可以在完成作业/考试后自动采集。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="question">选择题目</Label>
|
||||
<Select value={questionId} onValueChange={setQuestionId}>
|
||||
<SelectTrigger id="question">
|
||||
<SelectValue placeholder="从题库中选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{questionOptions.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.preview}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">学习笔记(可选)</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="记录错误原因、解题思路..."
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>错误原因标签</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COMMON_ERROR_TAGS.map((tag) => (
|
||||
<Button
|
||||
key={tag}
|
||||
type="button"
|
||||
variant={errorTags.includes(tag) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending || !questionId} onClick={handleSubmit}>
|
||||
{isPending ? "添加中..." : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
198
src/modules/error-book/components/class-error-overview.tsx
Normal file
198
src/modules/error-book/components/class-error-overview.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import Link from "next/link"
|
||||
import { Users, AlertTriangle, TrendingUp, Target } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate, formatNumber } from "@/shared/lib/utils"
|
||||
import type {
|
||||
StudentErrorBookSummary,
|
||||
KnowledgePointWeakness,
|
||||
SubjectErrorDistribution,
|
||||
} from "../types"
|
||||
|
||||
interface ClassErrorBookOverviewProps {
|
||||
totalStudents: number
|
||||
studentsWithErrorBook: number
|
||||
totalErrorItems: number
|
||||
averageMasteryRate: number
|
||||
topWeakKnowledgePoints: KnowledgePointWeakness[]
|
||||
subjectDistribution: SubjectErrorDistribution[]
|
||||
}
|
||||
|
||||
export function ClassErrorBookOverview({
|
||||
totalStudents,
|
||||
studentsWithErrorBook,
|
||||
totalErrorItems,
|
||||
averageMasteryRate,
|
||||
topWeakKnowledgePoints,
|
||||
subjectDistribution,
|
||||
}: ClassErrorBookOverviewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="覆盖学生"
|
||||
value={`${studentsWithErrorBook}/${totalStudents}`}
|
||||
icon={Users}
|
||||
description="有错题记录的学生数"
|
||||
/>
|
||||
<StatCard
|
||||
title="错题总数"
|
||||
value={totalErrorItems}
|
||||
icon={AlertTriangle}
|
||||
color="text-rose-500"
|
||||
description="班级累计错题"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均掌握率"
|
||||
value={`${formatNumber(averageMasteryRate * 100, 0)}%`}
|
||||
icon={TrendingUp}
|
||||
color="text-emerald-500"
|
||||
description="已掌握错题占比"
|
||||
/>
|
||||
<StatCard
|
||||
title="薄弱知识点"
|
||||
value={topWeakKnowledgePoints.length}
|
||||
icon={Target}
|
||||
color="text-amber-500"
|
||||
description="需重点讲解"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 薄弱知识点 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Target className="h-4 w-4" />
|
||||
薄弱知识点 Top 10
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topWeakKnowledgePoints.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
暂无数据
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topWeakKnowledgePoints.map((kp, idx) => (
|
||||
<div key={kp.knowledgePointId} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="w-6 justify-center">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<span className="line-clamp-1">{kp.knowledgePointName}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{kp.errorCount} 错 · {formatNumber(kp.masteryRate * 100, 0)}% 掌握
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={kp.masteryRate * 100} className="h-1.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 学科分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">学科错题分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{subjectDistribution.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
暂无数据
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{subjectDistribution.map((s) => (
|
||||
<div key={s.subjectId ?? "none"} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{s.subjectName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{s.errorCount} 错 · {formatNumber(s.masteryRate * 100, 0)}% 掌握
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={s.masteryRate * 100} className="h-1.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StudentErrorTableProps {
|
||||
students: StudentErrorBookSummary[]
|
||||
studentNames: Map<string, string>
|
||||
basePath: string
|
||||
}
|
||||
|
||||
export function StudentErrorTable({ students, studentNames, basePath }: StudentErrorTableProps) {
|
||||
if (students.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="暂无学生错题数据"
|
||||
description="学生完成作业或考试后,错题数据会自动汇总到这里。"
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">学生</th>
|
||||
<th className="px-4 py-3 text-right font-medium">错题总数</th>
|
||||
<th className="px-4 py-3 text-right font-medium">待学习</th>
|
||||
<th className="px-4 py-3 text-right font-medium">学习中</th>
|
||||
<th className="px-4 py-3 text-right font-medium">已掌握</th>
|
||||
<th className="px-4 py-3 text-right font-medium">待复习</th>
|
||||
<th className="px-4 py-3 text-right font-medium">掌握率</th>
|
||||
<th className="px-4 py-3 text-right font-medium">最近活动</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((s) => {
|
||||
const name = studentNames.get(s.studentId) ?? "未知"
|
||||
return (
|
||||
<tr key={s.studentId} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`${basePath}?studentId=${s.studentId}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">{s.totalCount}</td>
|
||||
<td className="px-4 py-3 text-right text-blue-600 dark:text-blue-400">{s.newCount}</td>
|
||||
<td className="px-4 py-3 text-right text-amber-600 dark:text-amber-400">{s.learningCount}</td>
|
||||
<td className="px-4 py-3 text-right text-emerald-600 dark:text-emerald-400">{s.masteredCount}</td>
|
||||
<td className="px-4 py-3 text-right text-rose-600 dark:text-rose-400">{s.dueReviewCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{formatNumber(s.masteredRate * 100, 0)}%
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground text-xs">
|
||||
{s.lastActivityAt ? formatDate(s.lastActivityAt) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/modules/error-book/components/error-book-filters.tsx
Normal file
79
src/modules/error-book/components/error-book-filters.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function ErrorBookFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
|
||||
const [dueOnly, setDueOnly] = useQueryState("due", parseAsString.withDefault("all"))
|
||||
|
||||
const hasFilters = Boolean(
|
||||
search || status !== "all" || sourceType !== "all" || dueOnly !== "all",
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
layout="between"
|
||||
gapClassName="gap-4"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
setSourceType(null)
|
||||
setDueOnly(null)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="搜索笔记内容..."
|
||||
className="flex-1 md:max-w-sm"
|
||||
inputClassName="border-muted-foreground/20 pl-8"
|
||||
/>
|
||||
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="new">待学习</SelectItem>
|
||||
<SelectItem value="learning">学习中</SelectItem>
|
||||
<SelectItem value="mastered">已掌握</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sourceType} onValueChange={(val) => setSourceType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
<SelectItem value="exam">考试</SelectItem>
|
||||
<SelectItem value="homework">作业</SelectItem>
|
||||
<SelectItem value="manual">手动添加</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={dueOnly} onValueChange={(val) => setDueOnly(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="复习" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部错题</SelectItem>
|
||||
<SelectItem value="due">仅看待复习</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
136
src/modules/error-book/components/error-book-item-card.tsx
Normal file
136
src/modules/error-book/components/error-book-item-card.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Calendar, FileText, BookMarked } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
ERROR_BOOK_SOURCE_LABEL,
|
||||
ERROR_BOOK_SOURCE_VARIANT,
|
||||
ERROR_BOOK_STATUS_LABEL,
|
||||
ERROR_BOOK_STATUS_VARIANT,
|
||||
type ErrorBookItem,
|
||||
} from "../types"
|
||||
|
||||
interface ErrorBookItemCardProps {
|
||||
item: ErrorBookItem
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/** 从题目内容中提取纯文本预览 */
|
||||
function extractQuestionPreview(content: unknown): string {
|
||||
if (typeof content === "string") return content
|
||||
if (Array.isArray(content)) {
|
||||
const texts: string[] = []
|
||||
for (const node of content) {
|
||||
if (typeof node === "string") {
|
||||
texts.push(node)
|
||||
} else if (typeof node === "object" && node !== null) {
|
||||
const n = node as Record<string, unknown>
|
||||
if (typeof n.text === "string") texts.push(n.text)
|
||||
if (Array.isArray(n.children)) {
|
||||
texts.push(extractQuestionPreview(n.children))
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts.join("")
|
||||
}
|
||||
if (typeof content === "object" && content !== null) {
|
||||
const c = content as Record<string, unknown>
|
||||
if (typeof c.text === "string") return c.text
|
||||
}
|
||||
return "(题目内容)"
|
||||
}
|
||||
|
||||
const MASTERY_LEVEL_LABELS: Record<number, string> = {
|
||||
0: "未学习",
|
||||
1: "入门",
|
||||
2: "了解",
|
||||
3: "熟悉",
|
||||
4: "熟练",
|
||||
5: "掌握",
|
||||
}
|
||||
|
||||
export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
|
||||
const preview = item.question ? extractQuestionPreview(item.question.content) : "(题目已删除)"
|
||||
const isDue = item.nextReviewAt ? item.nextReviewAt <= new Date() : false
|
||||
const isMastered = item.status === "mastered"
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-colors hover:bg-accent/50",
|
||||
isDue && !isMastered && "border-rose-200 bg-rose-50/30 dark:border-rose-900 dark:bg-rose-950/10"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
status={item.status}
|
||||
variantMap={ERROR_BOOK_STATUS_VARIANT}
|
||||
labelMap={ERROR_BOOK_STATUS_LABEL}
|
||||
capitalize={false}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={item.sourceType}
|
||||
variantMap={ERROR_BOOK_SOURCE_VARIANT}
|
||||
labelMap={ERROR_BOOK_SOURCE_LABEL}
|
||||
capitalize={false}
|
||||
/>
|
||||
{item.subjectName ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<BookMarked className="h-3 w-3" />
|
||||
{item.subjectName}
|
||||
</Badge>
|
||||
) : null}
|
||||
{item.question?.difficulty ? (
|
||||
<Badge variant="secondary">
|
||||
难度 {item.question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="line-clamp-2 text-sm">
|
||||
{preview}
|
||||
</div>
|
||||
|
||||
{item.errorTags && item.errorTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.errorTags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.note ? (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<FileText className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span className="line-clamp-2">{item.note}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>掌握度: {MASTERY_LEVEL_LABELS[item.masteryLevel] ?? item.masteryLevel}</span>
|
||||
<span>复习 {item.reviewCount} 次</span>
|
||||
{item.nextReviewAt && !isMastered ? (
|
||||
<span className={cn(isDue && "font-medium text-rose-600 dark:text-rose-400")}>
|
||||
{isDue ? "需复习" : `下次 ${formatDate(item.nextReviewAt)}`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
60
src/modules/error-book/components/error-book-stats-cards.tsx
Normal file
60
src/modules/error-book/components/error-book-stats-cards.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
|
||||
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import type { ErrorBookStats } from "../types"
|
||||
|
||||
interface ErrorBookStatsCardsProps {
|
||||
stats: ErrorBookStats
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
|
||||
const masteredPercent = stats.totalCount > 0
|
||||
? Math.round(stats.masteredRate * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatCard
|
||||
title="错题总数"
|
||||
value={stats.totalCount}
|
||||
icon={BookX}
|
||||
description="累计收录的错题"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="待学习"
|
||||
value={stats.newCount}
|
||||
icon={Sparkles}
|
||||
color="text-blue-500"
|
||||
description="尚未开始复习"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="学习中"
|
||||
value={stats.learningCount}
|
||||
icon={Repeat}
|
||||
color="text-amber-500"
|
||||
description="正在复习掌握"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="已掌握"
|
||||
value={stats.masteredCount}
|
||||
icon={GraduationCap}
|
||||
color="text-emerald-500"
|
||||
description={`掌握率 ${masteredPercent}%`}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="待复习"
|
||||
value={stats.dueReviewCount}
|
||||
icon={Clock}
|
||||
color="text-rose-500"
|
||||
description="今日到期复习"
|
||||
highlight={stats.dueReviewCount > 0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
src/modules/error-book/components/review-buttons.tsx
Normal file
98
src/modules/error-book/components/review-buttons.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { reviewErrorBookItemAction } from "../actions"
|
||||
import type { ErrorBookReviewResultValue } from "../types"
|
||||
|
||||
interface ReviewButtonsProps {
|
||||
itemId: string
|
||||
onReviewed?: () => void
|
||||
}
|
||||
|
||||
const REVIEW_OPTIONS: Array<{
|
||||
result: ErrorBookReviewResultValue
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof RotateCcw
|
||||
variant: "destructive" | "secondary" | "default" | "outline"
|
||||
}> = [
|
||||
{
|
||||
result: "again",
|
||||
label: "重来",
|
||||
description: "完全不会,明天再复习",
|
||||
icon: RotateCcw,
|
||||
variant: "destructive",
|
||||
},
|
||||
{
|
||||
result: "hard",
|
||||
label: "困难",
|
||||
description: "勉强答对,2 天后复习",
|
||||
icon: Zap,
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
result: "good",
|
||||
label: "良好",
|
||||
description: "正常答对,4 天后复习",
|
||||
icon: ThumbsUp,
|
||||
variant: "default",
|
||||
},
|
||||
{
|
||||
result: "easy",
|
||||
label: "简单",
|
||||
description: "轻松答对,7 天后复习",
|
||||
icon: Check,
|
||||
variant: "outline",
|
||||
},
|
||||
]
|
||||
|
||||
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
|
||||
|
||||
function handleReview(result: ErrorBookReviewResultValue) {
|
||||
setSelected(result)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("json", JSON.stringify({ itemId, result }))
|
||||
const res = await reviewErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "复习结果已记录")
|
||||
onReviewed?.()
|
||||
} else {
|
||||
toast.error(res.message ?? "记录失败")
|
||||
setSelected(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{REVIEW_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
const isLoading = isPending && selected === opt.result
|
||||
return (
|
||||
<Button
|
||||
key={opt.result}
|
||||
variant={opt.variant}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => handleReview(opt.result)}
|
||||
className="flex flex-col items-center gap-1 h-auto py-3"
|
||||
>
|
||||
<Icon className="h-4 w-4" data-icon="inline-start" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-[10px] font-normal text-muted-foreground">
|
||||
{opt.description}
|
||||
</span>
|
||||
{isLoading ? "..." : null}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
src/modules/error-book/components/top-wrong-questions.tsx
Normal file
109
src/modules/error-book/components/top-wrong-questions.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Flame } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
interface TopWrongQuestion {
|
||||
questionId: string
|
||||
questionContent: unknown
|
||||
questionType: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
}
|
||||
|
||||
interface TopWrongQuestionsProps {
|
||||
questions: TopWrongQuestion[]
|
||||
}
|
||||
|
||||
function extractPreview(content: unknown): string {
|
||||
if (typeof content === "string") return content.slice(0, 120)
|
||||
if (Array.isArray(content)) {
|
||||
const texts: string[] = []
|
||||
for (const node of content) {
|
||||
if (typeof node === "string") texts.push(node)
|
||||
else if (typeof node === "object" && node !== null) {
|
||||
const n = node as Record<string, unknown>
|
||||
if (typeof n.text === "string") texts.push(n.text)
|
||||
}
|
||||
}
|
||||
return texts.join("").slice(0, 120)
|
||||
}
|
||||
return "题目内容"
|
||||
}
|
||||
|
||||
const QUESTION_TYPE_LABEL: Record<string, string> = {
|
||||
single_choice: "单选",
|
||||
multiple_choice: "多选",
|
||||
judgment: "判断",
|
||||
text: "简答",
|
||||
composite: "复合",
|
||||
}
|
||||
|
||||
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Flame className="h-4 w-4" />
|
||||
高频错题
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Flame}
|
||||
title="暂无高频错题"
|
||||
description="学生完成作业或考试后,错频统计会显示在这里。"
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Flame className="h-4 w-4" />
|
||||
高频错题 Top 10
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{questions.map((q, idx) => {
|
||||
const masteryRate = q.errorCount > 0 ? q.masteredCount / q.errorCount : 0
|
||||
return (
|
||||
<div
|
||||
key={q.questionId}
|
||||
className="flex items-start gap-3 rounded-md border p-3"
|
||||
>
|
||||
<Badge variant="outline" className="mt-0.5 shrink-0">
|
||||
#{idx + 1}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm line-clamp-2">
|
||||
{extractPreview(q.questionContent)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{QUESTION_TYPE_LABEL[q.questionType] ?? q.questionType}
|
||||
</Badge>
|
||||
<span>{q.errorCount} 人错</span>
|
||||
<span>·</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{q.masteredCount} 人已掌握
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>掌握率 {Math.round(masteryRate * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
944
src/modules/error-book/data-access.ts
Normal file
944
src/modules/error-book/data-access.ts
Normal file
@@ -0,0 +1,944 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
errorBookItems,
|
||||
errorBookReviews,
|
||||
examSubmissions,
|
||||
submissionAnswers,
|
||||
homeworkSubmissions,
|
||||
homeworkAnswers,
|
||||
questions,
|
||||
questionsToKnowledgePoints,
|
||||
knowledgePoints,
|
||||
subjects,
|
||||
examQuestions,
|
||||
homeworkAssignmentQuestions,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import {
|
||||
calculateNewInterval,
|
||||
calculateNewMastery,
|
||||
deriveStatus,
|
||||
calculateNextReviewAt,
|
||||
calculateNewCorrectStreak,
|
||||
} from "./sm2-algorithm"
|
||||
|
||||
import type {
|
||||
ErrorBookItem,
|
||||
ErrorBookItemDetail,
|
||||
ErrorBookListResult,
|
||||
ErrorBookReviewRecord,
|
||||
ErrorBookStats,
|
||||
ErrorBookStatusValue,
|
||||
GetErrorBookItemsParams,
|
||||
} from "./types"
|
||||
import type { ErrorBookReviewResult } from "./schema"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-2 间隔重复算法(简化版)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型守卫
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isReviewResult = (v: unknown): v is ErrorBookReviewResult =>
|
||||
v === "again" || v === "hard" || v === "good" || v === "easy"
|
||||
|
||||
const toReviewResult = (v: string | null | undefined): ErrorBookReviewResult =>
|
||||
isReviewResult(v) ? v : "again"
|
||||
|
||||
const isStatus = (v: unknown): v is ErrorBookStatusValue =>
|
||||
v === "new" || v === "learning" || v === "mastered" || v === "archived"
|
||||
|
||||
const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
|
||||
isStatus(v) ? v : "new"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 行映射
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
|
||||
question?: typeof questions.$inferSelect | null
|
||||
subject?: typeof subjects.$inferSelect | null
|
||||
}): ErrorBookItem {
|
||||
return {
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
questionId: row.questionId,
|
||||
sourceType: row.sourceType as ErrorBookItem["sourceType"],
|
||||
sourceId: row.sourceId,
|
||||
studentAnswer: row.studentAnswer,
|
||||
correctAnswer: row.correctAnswer,
|
||||
subjectId: row.subjectId,
|
||||
knowledgePointIds: row.knowledgePointIds as string[] | null,
|
||||
status: toStatus(row.status),
|
||||
masteryLevel: row.masteryLevel,
|
||||
nextReviewAt: row.nextReviewAt,
|
||||
reviewInterval: row.reviewInterval,
|
||||
reviewCount: row.reviewCount,
|
||||
correctStreak: row.correctStreak,
|
||||
note: row.note,
|
||||
errorTags: row.errorTags as string[] | null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
question: row.question
|
||||
? {
|
||||
id: row.question.id,
|
||||
content: row.question.content,
|
||||
type: row.question.type,
|
||||
difficulty: row.question.difficulty,
|
||||
}
|
||||
: null,
|
||||
subjectName: row.subject?.name ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询:错题本列表
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams): Promise<ErrorBookListResult> => {
|
||||
const { studentId, q, page = 1, pageSize = 20, status, sourceType, subjectId, dueOnly } = params
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const conditions: SQL[] = [eq(errorBookItems.studentId, studentId)]
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(errorBookItems.status, status))
|
||||
}
|
||||
|
||||
if (sourceType) {
|
||||
conditions.push(eq(errorBookItems.sourceType, sourceType))
|
||||
}
|
||||
|
||||
if (subjectId) {
|
||||
conditions.push(eq(errorBookItems.subjectId, subjectId))
|
||||
}
|
||||
|
||||
if (dueOnly) {
|
||||
const now = new Date()
|
||||
conditions.push(
|
||||
or(
|
||||
isNull(errorBookItems.nextReviewAt),
|
||||
lte(errorBookItems.nextReviewAt, now)
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
if (q && q.trim().length > 0) {
|
||||
const needle = `%${q.trim().toLowerCase()}%`
|
||||
conditions.push(sql`LOWER(CAST(${errorBookItems.note} AS CHAR)) LIKE ${needle}`)
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions)
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ value: count() })
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
const total = Number(totalResult?.value ?? 0)
|
||||
|
||||
const rows = await db.query.errorBookItems.findMany({
|
||||
where: whereClause,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
orderBy: [desc(errorBookItems.createdAt)],
|
||||
with: {
|
||||
question: {
|
||||
columns: {
|
||||
id: true,
|
||||
content: true,
|
||||
type: true,
|
||||
difficulty: true,
|
||||
},
|
||||
},
|
||||
subject: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
data: rows.map((row) => mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询:错题详情
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getErrorBookItemById = cache(async (
|
||||
itemId: string,
|
||||
studentId: string
|
||||
): Promise<ErrorBookItemDetail | null> => {
|
||||
const row = await db.query.errorBookItems.findFirst({
|
||||
where: and(
|
||||
eq(errorBookItems.id, itemId),
|
||||
eq(errorBookItems.studentId, studentId)
|
||||
),
|
||||
with: {
|
||||
question: {
|
||||
columns: {
|
||||
id: true,
|
||||
content: true,
|
||||
type: true,
|
||||
difficulty: true,
|
||||
},
|
||||
},
|
||||
subject: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
reviews: {
|
||||
orderBy: [desc(errorBookReviews.reviewedAt)],
|
||||
limit: 20,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!row) return null
|
||||
|
||||
const base = mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])
|
||||
const reviews: ErrorBookReviewRecord[] = (row.reviews ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
result: toReviewResult(r.result),
|
||||
reviewedAt: r.reviewedAt,
|
||||
newInterval: r.newInterval,
|
||||
newMasteryLevel: r.newMasteryLevel,
|
||||
}))
|
||||
|
||||
return { ...base, reviews }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询:错题本统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorBookStats> => {
|
||||
const now = new Date()
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
status: errorBookItems.status,
|
||||
nextReviewAt: errorBookItems.nextReviewAt,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(eq(errorBookItems.studentId, studentId))
|
||||
|
||||
const total = rows.length
|
||||
let newCount = 0
|
||||
let learningCount = 0
|
||||
let masteredCount = 0
|
||||
let archivedCount = 0
|
||||
let dueReviewCount = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const status = toStatus(row.status)
|
||||
if (status === "new") newCount++
|
||||
else if (status === "learning") learningCount++
|
||||
else if (status === "mastered") masteredCount++
|
||||
else if (status === "archived") archivedCount++
|
||||
|
||||
if (status !== "mastered" && status !== "archived") {
|
||||
if (!row.nextReviewAt || row.nextReviewAt <= now) {
|
||||
dueReviewCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: total,
|
||||
newCount,
|
||||
learningCount,
|
||||
masteredCount,
|
||||
archivedCount,
|
||||
dueReviewCount,
|
||||
masteredRate: total > 0 ? masteredCount / total : 0,
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:手动添加错题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createErrorBookItem(
|
||||
studentId: string,
|
||||
input: {
|
||||
questionId: string
|
||||
studentAnswer?: unknown
|
||||
correctAnswer?: unknown
|
||||
subjectId?: string
|
||||
knowledgePointIds?: string[]
|
||||
note?: string
|
||||
errorTags?: string[]
|
||||
}
|
||||
): Promise<string> {
|
||||
const newId = createId()
|
||||
const now = new Date()
|
||||
|
||||
// 如果未提供知识点,从题目关联中查询
|
||||
let knowledgePointIds = input.knowledgePointIds
|
||||
if (!knowledgePointIds || knowledgePointIds.length === 0) {
|
||||
const kps = await db
|
||||
.select({ id: questionsToKnowledgePoints.knowledgePointId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.questionId, input.questionId))
|
||||
knowledgePointIds = kps.map((k) => k.id)
|
||||
}
|
||||
|
||||
// 如果未提供学科,从题目关联中查询(暂留空,学科由调用方提供)
|
||||
|
||||
await db.insert(errorBookItems).values({
|
||||
id: newId,
|
||||
studentId,
|
||||
questionId: input.questionId,
|
||||
sourceType: "manual",
|
||||
sourceId: null,
|
||||
studentAnswer: input.studentAnswer ?? null,
|
||||
correctAnswer: input.correctAnswer ?? null,
|
||||
subjectId: input.subjectId ?? null,
|
||||
knowledgePointIds: knowledgePointIds ?? null,
|
||||
status: "new",
|
||||
masteryLevel: 0,
|
||||
nextReviewAt: now, // 立即可复习
|
||||
reviewInterval: 1,
|
||||
reviewCount: 0,
|
||||
correctStreak: 0,
|
||||
note: input.note ?? null,
|
||||
errorTags: input.errorTags ?? null,
|
||||
})
|
||||
|
||||
return newId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:更新笔记
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function updateErrorBookNote(
|
||||
itemId: string,
|
||||
studentId: string,
|
||||
input: { note?: string; errorTags?: string[] }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(errorBookItems)
|
||||
.set({
|
||||
...(input.note !== undefined ? { note: input.note } : {}),
|
||||
...(input.errorTags !== undefined ? { errorTags: input.errorTags } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.id, itemId),
|
||||
eq(errorBookItems.studentId, studentId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:记录复习结果(SM-2 算法)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function recordReview(
|
||||
itemId: string,
|
||||
studentId: string,
|
||||
result: ErrorBookReviewResult
|
||||
): Promise<void> {
|
||||
const item = await db.query.errorBookItems.findFirst({
|
||||
where: and(
|
||||
eq(errorBookItems.id, itemId),
|
||||
eq(errorBookItems.studentId, studentId)
|
||||
),
|
||||
})
|
||||
|
||||
if (!item) throw new Error("错题条目不存在")
|
||||
|
||||
const newInterval = calculateNewInterval(item.reviewInterval, result, item.reviewCount)
|
||||
const newStreak = calculateNewCorrectStreak(item.correctStreak, result)
|
||||
const newMastery = calculateNewMastery(item.masteryLevel, result, newStreak)
|
||||
const newStatus = deriveStatus(newMastery, newStreak)
|
||||
const nextReviewAt = newStatus === "mastered" ? null : calculateNextReviewAt(newInterval)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(errorBookReviews).values({
|
||||
id: createId(),
|
||||
itemId,
|
||||
studentId,
|
||||
result,
|
||||
reviewedAt: new Date(),
|
||||
newInterval,
|
||||
newMasteryLevel: newMastery,
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(errorBookItems)
|
||||
.set({
|
||||
status: newStatus,
|
||||
masteryLevel: newMastery,
|
||||
reviewInterval: newInterval,
|
||||
reviewCount: item.reviewCount + 1,
|
||||
correctStreak: newStreak,
|
||||
nextReviewAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(errorBookItems.id, itemId))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:删除错题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function deleteErrorBookItem(itemId: string, studentId: string): Promise<void> {
|
||||
await db
|
||||
.delete(errorBookItems)
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.id, itemId),
|
||||
eq(errorBookItems.studentId, studentId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:归档错题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function archiveErrorBookItem(itemId: string, studentId: string): Promise<void> {
|
||||
await db
|
||||
.update(errorBookItems)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.id, itemId),
|
||||
eq(errorBookItems.studentId, studentId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 自动采集:从考试提交中收集错题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function collectFromExamSubmission(
|
||||
submissionId: string,
|
||||
studentId: string
|
||||
): Promise<number> {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(examSubmissions.id, submissionId),
|
||||
eq(examSubmissions.studentId, studentId)
|
||||
),
|
||||
})
|
||||
|
||||
if (!submission) throw new Error("考试提交记录不存在")
|
||||
|
||||
// 查询该提交的所有作答
|
||||
const answers = await db
|
||||
.select({
|
||||
answerId: submissionAnswers.id,
|
||||
questionId: submissionAnswers.questionId,
|
||||
answerContent: submissionAnswers.answerContent,
|
||||
score: submissionAnswers.score,
|
||||
feedback: submissionAnswers.feedback,
|
||||
})
|
||||
.from(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
|
||||
// 查询题目满分(用于判断是否答错)
|
||||
const questionIds = answers.map((a) => a.questionId)
|
||||
const examQuestionScores = await db
|
||||
.select({
|
||||
questionId: examQuestions.questionId,
|
||||
maxScore: examQuestions.score,
|
||||
})
|
||||
.from(examQuestions)
|
||||
.where(
|
||||
and(
|
||||
eq(examQuestions.examId, submission.examId),
|
||||
inArray(examQuestions.questionId, questionIds)
|
||||
)
|
||||
)
|
||||
|
||||
const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
|
||||
|
||||
// 筛选错题:得分为 0 或低于满分
|
||||
const wrongAnswers = answers.filter((a) => {
|
||||
const max = maxScoreMap.get(a.questionId) ?? 0
|
||||
return (a.score ?? 0) < max
|
||||
})
|
||||
|
||||
if (wrongAnswers.length === 0) return 0
|
||||
|
||||
// 查询已存在的错题,避免重复
|
||||
const existing = await db
|
||||
.select({ questionId: errorBookItems.questionId })
|
||||
.from(errorBookItems)
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.studentId, studentId),
|
||||
inArray(
|
||||
errorBookItems.questionId,
|
||||
wrongAnswers.map((a) => a.questionId)
|
||||
)
|
||||
)
|
||||
)
|
||||
const existingSet = new Set(existing.map((e) => e.questionId))
|
||||
|
||||
// 查询题目关联的知识点
|
||||
const kpRows = await db
|
||||
.select({
|
||||
questionId: questionsToKnowledgePoints.questionId,
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(
|
||||
inArray(
|
||||
questionsToKnowledgePoints.questionId,
|
||||
wrongAnswers.map((a) => a.questionId)
|
||||
)
|
||||
)
|
||||
const kpMap = new Map<string, string[]>()
|
||||
for (const kp of kpRows) {
|
||||
const list = kpMap.get(kp.questionId) ?? []
|
||||
list.push(kp.knowledgePointId)
|
||||
kpMap.set(kp.questionId, list)
|
||||
}
|
||||
|
||||
// 批量插入
|
||||
const now = new Date()
|
||||
const toInsert = wrongAnswers
|
||||
.filter((a) => !existingSet.has(a.questionId))
|
||||
.map((a) => ({
|
||||
id: createId(),
|
||||
studentId,
|
||||
questionId: a.questionId,
|
||||
sourceType: "exam" as const,
|
||||
sourceId: submissionId,
|
||||
studentAnswer: a.answerContent,
|
||||
correctAnswer: null,
|
||||
subjectId: null,
|
||||
knowledgePointIds: kpMap.get(a.questionId) ?? null,
|
||||
status: "new" as const,
|
||||
masteryLevel: 0,
|
||||
nextReviewAt: now,
|
||||
reviewInterval: 1,
|
||||
reviewCount: 0,
|
||||
correctStreak: 0,
|
||||
note: a.feedback ?? null,
|
||||
errorTags: null,
|
||||
}))
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await db.insert(errorBookItems).values(toInsert)
|
||||
}
|
||||
|
||||
return toInsert.length
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 自动采集:从作业提交中收集错题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function collectFromHomeworkSubmission(
|
||||
submissionId: string,
|
||||
studentId: string
|
||||
): Promise<number> {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
})
|
||||
|
||||
if (!submission) throw new Error("作业提交记录不存在")
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
answerId: homeworkAnswers.id,
|
||||
questionId: homeworkAnswers.questionId,
|
||||
answerContent: homeworkAnswers.answerContent,
|
||||
score: homeworkAnswers.score,
|
||||
feedback: homeworkAnswers.feedback,
|
||||
})
|
||||
.from(homeworkAnswers)
|
||||
.where(eq(homeworkAnswers.submissionId, submissionId))
|
||||
|
||||
// 查询题目满分
|
||||
const questionIds = answers.map((a) => a.questionId)
|
||||
const hwQuestionScores = await db
|
||||
.select({
|
||||
questionId: homeworkAssignmentQuestions.questionId,
|
||||
maxScore: homeworkAssignmentQuestions.score,
|
||||
})
|
||||
.from(homeworkAssignmentQuestions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
||||
inArray(homeworkAssignmentQuestions.questionId, questionIds)
|
||||
)
|
||||
)
|
||||
|
||||
const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
|
||||
|
||||
const wrongAnswers = answers.filter((a) => {
|
||||
const max = maxScoreMap.get(a.questionId) ?? 0
|
||||
return (a.score ?? 0) < max
|
||||
})
|
||||
|
||||
if (wrongAnswers.length === 0) return 0
|
||||
|
||||
// 去重
|
||||
const existing = await db
|
||||
.select({ questionId: errorBookItems.questionId })
|
||||
.from(errorBookItems)
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.studentId, studentId),
|
||||
inArray(
|
||||
errorBookItems.questionId,
|
||||
wrongAnswers.map((a) => a.questionId)
|
||||
)
|
||||
)
|
||||
)
|
||||
const existingSet = new Set(existing.map((e) => e.questionId))
|
||||
|
||||
// 查询知识点
|
||||
const kpRows = await db
|
||||
.select({
|
||||
questionId: questionsToKnowledgePoints.questionId,
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(
|
||||
inArray(
|
||||
questionsToKnowledgePoints.questionId,
|
||||
wrongAnswers.map((a) => a.questionId)
|
||||
)
|
||||
)
|
||||
const kpMap = new Map<string, string[]>()
|
||||
for (const kp of kpRows) {
|
||||
const list = kpMap.get(kp.questionId) ?? []
|
||||
list.push(kp.knowledgePointId)
|
||||
kpMap.set(kp.questionId, list)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const toInsert = wrongAnswers
|
||||
.filter((a) => !existingSet.has(a.questionId))
|
||||
.map((a) => ({
|
||||
id: createId(),
|
||||
studentId,
|
||||
questionId: a.questionId,
|
||||
sourceType: "homework" as const,
|
||||
sourceId: submissionId,
|
||||
studentAnswer: a.answerContent,
|
||||
correctAnswer: null,
|
||||
subjectId: null,
|
||||
knowledgePointIds: kpMap.get(a.questionId) ?? null,
|
||||
status: "new" as const,
|
||||
masteryLevel: 0,
|
||||
nextReviewAt: now,
|
||||
reviewInterval: 1,
|
||||
reviewCount: 0,
|
||||
correctStreak: 0,
|
||||
note: a.feedback ?? null,
|
||||
errorTags: null,
|
||||
}))
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await db.insert(errorBookItems).values(toInsert)
|
||||
}
|
||||
|
||||
return toInsert.length
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 跨模块查询接口:供教师/家长视图使用
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 查询多个学生的错题统计(教师视图) */
|
||||
export async function getStudentErrorBookSummaries(
|
||||
studentIds: string[]
|
||||
): Promise<Array<{
|
||||
studentId: string
|
||||
totalCount: number
|
||||
newCount: number
|
||||
learningCount: number
|
||||
masteredCount: number
|
||||
dueReviewCount: number
|
||||
masteredRate: number
|
||||
lastActivityAt: Date | null
|
||||
}>> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: errorBookItems.studentId,
|
||||
status: errorBookItems.status,
|
||||
nextReviewAt: errorBookItems.nextReviewAt,
|
||||
updatedAt: errorBookItems.updatedAt,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
|
||||
const map = new Map<string, {
|
||||
totalCount: number
|
||||
newCount: number
|
||||
learningCount: number
|
||||
masteredCount: number
|
||||
dueReviewCount: number
|
||||
lastActivityAt: Date | null
|
||||
}>()
|
||||
|
||||
for (const row of rows) {
|
||||
const stat = map.get(row.studentId) ?? {
|
||||
totalCount: 0,
|
||||
newCount: 0,
|
||||
learningCount: 0,
|
||||
masteredCount: 0,
|
||||
dueReviewCount: 0,
|
||||
lastActivityAt: null,
|
||||
}
|
||||
stat.totalCount++
|
||||
const status = toStatus(row.status)
|
||||
if (status === "new") stat.newCount++
|
||||
else if (status === "learning") stat.learningCount++
|
||||
else if (status === "mastered") stat.masteredCount++
|
||||
|
||||
if (status !== "mastered" && status !== "archived") {
|
||||
if (!row.nextReviewAt || row.nextReviewAt <= now) {
|
||||
stat.dueReviewCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (!stat.lastActivityAt || row.updatedAt > stat.lastActivityAt) {
|
||||
stat.lastActivityAt = row.updatedAt
|
||||
}
|
||||
|
||||
map.set(row.studentId, stat)
|
||||
}
|
||||
|
||||
return Array.from(map.entries()).map(([studentId, stat]) => ({
|
||||
studentId,
|
||||
...stat,
|
||||
masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 查询班级内错题最多的题目(教师视图:高频错题) */
|
||||
export async function getTopWrongQuestionsByStudentIds(
|
||||
studentIds: string[],
|
||||
limit = 10
|
||||
): Promise<Array<{
|
||||
questionId: string
|
||||
questionContent: unknown
|
||||
questionType: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
}>> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
questionId: errorBookItems.questionId,
|
||||
status: errorBookItems.status,
|
||||
content: questions.content,
|
||||
type: questions.type,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.innerJoin(questions, eq(questions.id, errorBookItems.questionId))
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
|
||||
const map = new Map<string, { questionContent: unknown; questionType: string; errorCount: number; masteredCount: number }>()
|
||||
|
||||
for (const row of rows) {
|
||||
const stat = map.get(row.questionId) ?? {
|
||||
questionContent: row.content,
|
||||
questionType: row.type,
|
||||
errorCount: 0,
|
||||
masteredCount: 0,
|
||||
}
|
||||
stat.errorCount++
|
||||
if (toStatus(row.status) === "mastered") stat.masteredCount++
|
||||
map.set(row.questionId, stat)
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.map(([questionId, stat]) => ({ questionId, ...stat }))
|
||||
.sort((a, b) => b.errorCount - a.errorCount)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
|
||||
export async function getStudentIdsByClassIdList(classIds: string[]): Promise<string[]> {
|
||||
return await getStudentIdsByClassIds(classIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有学生用户 ID(管理员视图)。
|
||||
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
|
||||
* 此函数封装了 DB 访问,避免 app 层直接查询 DB(遵循三层架构)。
|
||||
*/
|
||||
export async function getAllStudentIds(): Promise<string[]> {
|
||||
const { usersToRoles, roles } = await import("@/shared/db/schema")
|
||||
const studentRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, "student"))
|
||||
.limit(1)
|
||||
|
||||
if (studentRole.length === 0) return []
|
||||
|
||||
const userRoleRows = await db
|
||||
.select({ userId: usersToRoles.userId })
|
||||
.from(usersToRoles)
|
||||
.where(eq(usersToRoles.roleId, studentRole[0].id))
|
||||
|
||||
return userRoleRows.map((r) => r.userId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 统计:知识点薄弱度 & 学科分布(教师/管理员视图)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 查询多个学生的知识点薄弱度统计 */
|
||||
export async function getKnowledgePointWeakness(
|
||||
studentIds: string[],
|
||||
limit = 10
|
||||
): Promise<Array<{
|
||||
knowledgePointId: string
|
||||
knowledgePointName: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
totalCount: number
|
||||
masteryRate: number
|
||||
}>> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
// 查询这些学生的所有错题条目(含知识点)
|
||||
const rows = await db
|
||||
.select({
|
||||
itemId: errorBookItems.id,
|
||||
status: errorBookItems.status,
|
||||
knowledgePointIds: errorBookItems.knowledgePointIds,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
|
||||
// 展开知识点并统计
|
||||
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
|
||||
|
||||
for (const row of rows) {
|
||||
const kps = (row.knowledgePointIds as string[] | null) ?? []
|
||||
for (const kpId of kps) {
|
||||
const stat = kpMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
|
||||
stat.errorCount++
|
||||
if (toStatus(row.status) === "mastered") stat.masteredCount++
|
||||
kpMap.set(kpId, stat)
|
||||
}
|
||||
}
|
||||
|
||||
if (kpMap.size === 0) return []
|
||||
|
||||
// 查询知识点名称
|
||||
const kpIds = Array.from(kpMap.keys())
|
||||
const kpRows = await db
|
||||
.select({ id: knowledgePoints.id, name: knowledgePoints.name })
|
||||
.from(knowledgePoints)
|
||||
.where(inArray(knowledgePoints.id, kpIds))
|
||||
const kpNameMap = new Map(kpRows.map((k) => [k.id, k.name]))
|
||||
|
||||
return Array.from(kpMap.entries())
|
||||
.map(([kpId, stat]) => ({
|
||||
knowledgePointId: kpId,
|
||||
knowledgePointName: kpNameMap.get(kpId) ?? "未知知识点",
|
||||
errorCount: stat.errorCount,
|
||||
masteredCount: stat.masteredCount,
|
||||
totalCount: stat.errorCount,
|
||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// 按错误数降序,掌握率升序(最薄弱的在前)
|
||||
if (b.errorCount !== a.errorCount) return b.errorCount - a.errorCount
|
||||
return a.masteryRate - b.masteryRate
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/** 查询多个学生的学科错题分布 */
|
||||
export async function getSubjectErrorDistribution(
|
||||
studentIds: string[]
|
||||
): Promise<Array<{
|
||||
subjectId: string | null
|
||||
subjectName: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
}>> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: errorBookItems.subjectId,
|
||||
status: errorBookItems.status,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
|
||||
const subjectMap = new Map<string | null, { errorCount: number; masteredCount: number }>()
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.subjectId
|
||||
const stat = subjectMap.get(key) ?? { errorCount: 0, masteredCount: 0 }
|
||||
stat.errorCount++
|
||||
if (toStatus(row.status) === "mastered") stat.masteredCount++
|
||||
subjectMap.set(key, stat)
|
||||
}
|
||||
|
||||
// 查询学科名称
|
||||
const subjectIds = Array.from(subjectMap.keys()).filter((k): k is string => k !== null)
|
||||
let subjectNameMap = new Map<string, string>()
|
||||
if (subjectIds.length > 0) {
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.id, subjectIds))
|
||||
subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
|
||||
}
|
||||
|
||||
return Array.from(subjectMap.entries()).map(([sid, stat]) => ({
|
||||
subjectId: sid,
|
||||
subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
|
||||
errorCount: stat.errorCount,
|
||||
masteredCount: stat.masteredCount,
|
||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 查询学生姓名映射 */
|
||||
export async function getStudentNameMap(studentIds: string[]): Promise<Map<string, string>> {
|
||||
if (studentIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, studentIds))
|
||||
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
|
||||
}
|
||||
51
src/modules/error-book/schema.ts
Normal file
51
src/modules/error-book/schema.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/** 错题来源类型 */
|
||||
export const ErrorBookSourceTypeEnum = z.enum(["exam", "homework", "manual"])
|
||||
export type ErrorBookSourceType = z.infer<typeof ErrorBookSourceTypeEnum>
|
||||
|
||||
/** 错题状态 */
|
||||
export const ErrorBookStatusEnum = z.enum(["new", "learning", "mastered", "archived"])
|
||||
export type ErrorBookStatus = z.infer<typeof ErrorBookStatusEnum>
|
||||
|
||||
/** 复习自评结果(SM-2 算法评级) */
|
||||
export const ErrorBookReviewResultEnum = z.enum(["again", "hard", "good", "easy"])
|
||||
export type ErrorBookReviewResult = z.infer<typeof ErrorBookReviewResultEnum>
|
||||
|
||||
/** 手动添加错题的输入 schema */
|
||||
export const CreateErrorBookItemSchema = z.object({
|
||||
questionId: z.string().min(1, "请选择题目"),
|
||||
studentAnswer: z.unknown().optional(),
|
||||
correctAnswer: z.unknown().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
note: z.string().max(2000, "笔记不能超过 2000 字").optional(),
|
||||
errorTags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type CreateErrorBookItemInput = z.infer<typeof CreateErrorBookItemSchema>
|
||||
|
||||
/** 更新错题笔记的 schema */
|
||||
export const UpdateErrorBookNoteSchema = z.object({
|
||||
itemId: z.string().min(1),
|
||||
note: z.string().max(2000, "笔记不能超过 2000 字").optional(),
|
||||
errorTags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type UpdateErrorBookNoteInput = z.infer<typeof UpdateErrorBookNoteSchema>
|
||||
|
||||
/** 记录复习结果的 schema */
|
||||
export const ReviewErrorBookItemSchema = z.object({
|
||||
itemId: z.string().min(1),
|
||||
result: ErrorBookReviewResultEnum,
|
||||
})
|
||||
|
||||
export type ReviewErrorBookItemInput = z.infer<typeof ReviewErrorBookItemSchema>
|
||||
|
||||
/** 从考试/作业提交自动采集错题的 schema */
|
||||
export const CollectFromSubmissionSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
sourceType: z.enum(["exam", "homework"]),
|
||||
})
|
||||
|
||||
export type CollectFromSubmissionInput = z.infer<typeof CollectFromSubmissionSchema>
|
||||
302
src/modules/error-book/sm2-algorithm.test.ts
Normal file
302
src/modules/error-book/sm2-algorithm.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
calculateNewInterval,
|
||||
calculateNewMastery,
|
||||
deriveStatus,
|
||||
calculateNextReviewAt,
|
||||
calculateNewCorrectStreak,
|
||||
calculateSm2Result,
|
||||
REVIEW_INTERVALS,
|
||||
INTERVAL_MULTIPLIERS,
|
||||
MAX_MASTERY_LEVEL,
|
||||
MIN_MASTERY_LEVEL,
|
||||
MASTERED_REQUIRED_STREAK,
|
||||
MASTERED_REQUIRED_MASTERY,
|
||||
} from "./sm2-algorithm"
|
||||
import type { ErrorBookReviewResult } from "./schema"
|
||||
|
||||
describe("SM-2 间隔重复算法", () => {
|
||||
describe("REVIEW_INTERVALS 常量", () => {
|
||||
it("应该包含 4 种评级的映射", () => {
|
||||
expect(REVIEW_INTERVALS.again).toBeDefined()
|
||||
expect(REVIEW_INTERVALS.hard).toBeDefined()
|
||||
expect(REVIEW_INTERVALS.good).toBeDefined()
|
||||
expect(REVIEW_INTERVALS.easy).toBeDefined()
|
||||
})
|
||||
|
||||
it("again 应该重置连续答对", () => {
|
||||
expect(REVIEW_INTERVALS.again.streakDelta).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it("good 和 easy 应该增加连续答对", () => {
|
||||
expect(REVIEW_INTERVALS.good.streakDelta).toBe(1)
|
||||
expect(REVIEW_INTERVALS.easy.streakDelta).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("INTERVAL_MULTIPLIERS 常量", () => {
|
||||
it("easy 的倍率应该最高", () => {
|
||||
expect(INTERVAL_MULTIPLIERS.easy).toBeGreaterThan(INTERVAL_MULTIPLIERS.good)
|
||||
expect(INTERVAL_MULTIPLIERS.good).toBeGreaterThan(INTERVAL_MULTIPLIERS.hard)
|
||||
})
|
||||
|
||||
it("所有倍率应该 >= 1", () => {
|
||||
for (const multiplier of Object.values(INTERVAL_MULTIPLIERS)) {
|
||||
expect(multiplier).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateNewInterval", () => {
|
||||
it("again 总是返回 1 天", () => {
|
||||
expect(calculateNewInterval(10, "again", 5)).toBe(1)
|
||||
expect(calculateNewInterval(100, "again", 10)).toBe(1)
|
||||
})
|
||||
|
||||
it("首次复习(reviewCount=0)使用基础间隔", () => {
|
||||
expect(calculateNewInterval(0, "hard", 0)).toBe(REVIEW_INTERVALS.hard.interval)
|
||||
expect(calculateNewInterval(0, "good", 0)).toBe(REVIEW_INTERVALS.good.interval)
|
||||
expect(calculateNewInterval(0, "easy", 0)).toBe(REVIEW_INTERVALS.easy.interval)
|
||||
})
|
||||
|
||||
it("good 评级应该按 ×1.5 增长", () => {
|
||||
const result = calculateNewInterval(10, "good", 1)
|
||||
expect(result).toBe(Math.max(REVIEW_INTERVALS.good.interval, Math.round(10 * 1.5)))
|
||||
expect(result).toBe(15)
|
||||
})
|
||||
|
||||
it("easy 评级应该按 ×2 增长", () => {
|
||||
const result = calculateNewInterval(10, "easy", 1)
|
||||
expect(result).toBe(Math.max(REVIEW_INTERVALS.easy.interval, Math.round(10 * 2)))
|
||||
expect(result).toBe(20)
|
||||
})
|
||||
|
||||
it("hard 评级应该按 ×1.2 增长", () => {
|
||||
const result = calculateNewInterval(10, "hard", 1)
|
||||
expect(result).toBe(Math.max(REVIEW_INTERVALS.hard.interval, Math.round(10 * 1.2)))
|
||||
expect(result).toBe(12)
|
||||
})
|
||||
|
||||
it("新间隔不应该小于基础间隔", () => {
|
||||
expect(calculateNewInterval(1, "good", 1)).toBeGreaterThanOrEqual(REVIEW_INTERVALS.good.interval)
|
||||
expect(calculateNewInterval(1, "easy", 1)).toBeGreaterThanOrEqual(REVIEW_INTERVALS.easy.interval)
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateNewMastery", () => {
|
||||
it("again 应该降低掌握度", () => {
|
||||
expect(calculateNewMastery(3, "again", 0)).toBe(2)
|
||||
expect(calculateNewMastery(5, "again", 0)).toBe(4)
|
||||
})
|
||||
|
||||
it("hard 应该保持掌握度不变", () => {
|
||||
expect(calculateNewMastery(3, "hard", 0)).toBe(3)
|
||||
expect(calculateNewMastery(0, "hard", 0)).toBe(0)
|
||||
})
|
||||
|
||||
it("good 应该增加掌握度", () => {
|
||||
expect(calculateNewMastery(2, "good", 0)).toBe(3)
|
||||
expect(calculateNewMastery(4, "good", 0)).toBe(5)
|
||||
})
|
||||
|
||||
it("easy 应该大幅增加掌握度", () => {
|
||||
expect(calculateNewMastery(1, "easy", 0)).toBe(3)
|
||||
expect(calculateNewMastery(3, "easy", 0)).toBe(5)
|
||||
})
|
||||
|
||||
it("掌握度不应该超过上限", () => {
|
||||
expect(calculateNewMastery(5, "easy", 0)).toBe(MAX_MASTERY_LEVEL)
|
||||
expect(calculateNewMastery(5, "good", 0)).toBe(MAX_MASTERY_LEVEL)
|
||||
})
|
||||
|
||||
it("掌握度不应该低于下限", () => {
|
||||
expect(calculateNewMastery(0, "again", 0)).toBe(MIN_MASTERY_LEVEL)
|
||||
expect(calculateNewMastery(0, "hard", 0)).toBe(MIN_MASTERY_LEVEL)
|
||||
})
|
||||
|
||||
it("连续 3 次答对应该直接达到已掌握", () => {
|
||||
expect(calculateNewMastery(2, "good", 3)).toBe(MAX_MASTERY_LEVEL)
|
||||
expect(calculateNewMastery(1, "easy", 3)).toBe(MAX_MASTERY_LEVEL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deriveStatus", () => {
|
||||
it("掌握度 0 应该返回 new", () => {
|
||||
expect(deriveStatus(0, 0)).toBe("new")
|
||||
})
|
||||
|
||||
it("掌握度 1-4 应该返回 learning", () => {
|
||||
expect(deriveStatus(1, 0)).toBe("learning")
|
||||
expect(deriveStatus(2, 0)).toBe("learning")
|
||||
expect(deriveStatus(3, 0)).toBe("learning")
|
||||
expect(deriveStatus(4, 0)).toBe("learning")
|
||||
})
|
||||
|
||||
it("掌握度 5 应该返回 mastered", () => {
|
||||
expect(deriveStatus(5, 0)).toBe("mastered")
|
||||
})
|
||||
|
||||
it("连续答对 3 次应该返回 mastered", () => {
|
||||
expect(deriveStatus(1, 3)).toBe("mastered")
|
||||
expect(deriveStatus(2, 3)).toBe("mastered")
|
||||
})
|
||||
|
||||
it("连续答对 2 次且掌握度 < 5 应该返回 learning", () => {
|
||||
expect(deriveStatus(2, 2)).toBe("learning")
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateNextReviewAt", () => {
|
||||
it("应该返回正确的日期(+intervalDays 天)", () => {
|
||||
const now = new Date("2026-01-15T10:00:00Z")
|
||||
const result = calculateNextReviewAt(7, now)
|
||||
expect(result.getDate()).toBe(now.getDate() + 7)
|
||||
})
|
||||
|
||||
it("应该设置时间为早上 9 点", () => {
|
||||
const now = new Date("2026-01-15T22:00:00Z")
|
||||
const result = calculateNextReviewAt(1, now)
|
||||
expect(result.getHours()).toBe(9)
|
||||
expect(result.getMinutes()).toBe(0)
|
||||
expect(result.getSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it("间隔为 0 天时应该返回当天", () => {
|
||||
const now = new Date("2026-01-15T10:00:00Z")
|
||||
const result = calculateNextReviewAt(0, now)
|
||||
expect(result.getDate()).toBe(now.getDate())
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateNewCorrectStreak", () => {
|
||||
it("again 应该重置连续答对为 0", () => {
|
||||
expect(calculateNewCorrectStreak(5, "again")).toBe(0)
|
||||
expect(calculateNewCorrectStreak(0, "again")).toBe(0)
|
||||
})
|
||||
|
||||
it("hard 应该保持连续答对不变", () => {
|
||||
expect(calculateNewCorrectStreak(3, "hard")).toBe(3)
|
||||
expect(calculateNewCorrectStreak(0, "hard")).toBe(0)
|
||||
})
|
||||
|
||||
it("good 应该增加连续答对", () => {
|
||||
expect(calculateNewCorrectStreak(2, "good")).toBe(3)
|
||||
expect(calculateNewCorrectStreak(0, "good")).toBe(1)
|
||||
})
|
||||
|
||||
it("easy 应该增加连续答对", () => {
|
||||
expect(calculateNewCorrectStreak(2, "easy")).toBe(3)
|
||||
expect(calculateNewCorrectStreak(0, "easy")).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateSm2Result", () => {
|
||||
it("应该返回完整的计算结果", () => {
|
||||
const result = calculateSm2Result(
|
||||
{
|
||||
currentInterval: 4,
|
||||
currentMastery: 2,
|
||||
currentStreak: 1,
|
||||
reviewCount: 2,
|
||||
},
|
||||
"good"
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("newInterval")
|
||||
expect(result).toHaveProperty("newMasteryLevel")
|
||||
expect(result).toHaveProperty("newCorrectStreak")
|
||||
expect(result).toHaveProperty("newStatus")
|
||||
expect(result).toHaveProperty("nextReviewAt")
|
||||
})
|
||||
|
||||
it("good 评级应该正确计算所有字段", () => {
|
||||
const result = calculateSm2Result(
|
||||
{
|
||||
currentInterval: 4,
|
||||
currentMastery: 2,
|
||||
currentStreak: 1,
|
||||
reviewCount: 2,
|
||||
},
|
||||
"good"
|
||||
)
|
||||
|
||||
expect(result.newInterval).toBe(6) // max(4, round(4 * 1.5)) = 6
|
||||
expect(result.newMasteryLevel).toBe(3) // 2 + 1 = 3
|
||||
expect(result.newCorrectStreak).toBe(2) // 1 + 1 = 2
|
||||
expect(result.newStatus).toBe("learning") // mastery 3, streak 2
|
||||
})
|
||||
|
||||
it("连续 3 次 good 应该达到 mastered", () => {
|
||||
const result = calculateSm2Result(
|
||||
{
|
||||
currentInterval: 4,
|
||||
currentMastery: 2,
|
||||
currentStreak: 2,
|
||||
reviewCount: 2,
|
||||
},
|
||||
"good"
|
||||
)
|
||||
|
||||
expect(result.newCorrectStreak).toBe(3)
|
||||
expect(result.newMasteryLevel).toBe(MAX_MASTERY_LEVEL) // 连续 3 次直接到 5
|
||||
expect(result.newStatus).toBe("mastered")
|
||||
})
|
||||
|
||||
it("again 应该重置所有进度", () => {
|
||||
const result = calculateSm2Result(
|
||||
{
|
||||
currentInterval: 20,
|
||||
currentMastery: 4,
|
||||
currentStreak: 2,
|
||||
reviewCount: 5,
|
||||
},
|
||||
"again"
|
||||
)
|
||||
|
||||
expect(result.newInterval).toBe(1)
|
||||
expect(result.newMasteryLevel).toBe(3) // 4 - 1 = 3
|
||||
expect(result.newCorrectStreak).toBe(0)
|
||||
expect(result.newStatus).toBe("learning") // mastery 3
|
||||
})
|
||||
|
||||
it("所有评级都应该产生有效结果", () => {
|
||||
const ratings: ErrorBookReviewResult[] = ["again", "hard", "good", "easy"]
|
||||
for (const rating of ratings) {
|
||||
const result = calculateSm2Result(
|
||||
{
|
||||
currentInterval: 4,
|
||||
currentMastery: 2,
|
||||
currentStreak: 1,
|
||||
reviewCount: 2,
|
||||
},
|
||||
rating
|
||||
)
|
||||
|
||||
expect(result.newInterval).toBeGreaterThan(0)
|
||||
expect(result.newMasteryLevel).toBeGreaterThanOrEqual(MIN_MASTERY_LEVEL)
|
||||
expect(result.newMasteryLevel).toBeLessThanOrEqual(MAX_MASTERY_LEVEL)
|
||||
expect(result.newCorrectStreak).toBeGreaterThanOrEqual(0)
|
||||
expect(["new", "learning", "mastered"]).toContain(result.newStatus)
|
||||
expect(result.nextReviewAt).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("常量一致性", () => {
|
||||
it("MASTERED_REQUIRED_STREAK 应该是 3", () => {
|
||||
expect(MASTERED_REQUIRED_STREAK).toBe(3)
|
||||
})
|
||||
|
||||
it("MASTERED_REQUIRED_MASTERY 应该是 5", () => {
|
||||
expect(MASTERED_REQUIRED_MASTERY).toBe(5)
|
||||
})
|
||||
|
||||
it("MAX_MASTERY_LEVEL 应该是 5", () => {
|
||||
expect(MAX_MASTERY_LEVEL).toBe(5)
|
||||
})
|
||||
|
||||
it("MIN_MASTERY_LEVEL 应该是 0", () => {
|
||||
expect(MIN_MASTERY_LEVEL).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
177
src/modules/error-book/sm2-algorithm.ts
Normal file
177
src/modules/error-book/sm2-algorithm.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* SM-2 间隔重复算法(简化版)
|
||||
*
|
||||
* 基于 SuperMemo SM-2 算法的简化实现,适用于 K12 错题本场景。
|
||||
* 4 级评级:again(重来)/ hard(困难)/ good(良好)/ easy(简单)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 纯函数,无副作用,易于测试
|
||||
* - 算法独立于数据访问层,可替换为其他算法(如 FSRS)
|
||||
* - 所有函数可单独测试
|
||||
*/
|
||||
|
||||
import type { ErrorBookReviewResult } from "./schema"
|
||||
import type { ErrorBookStatusValue } from "./types"
|
||||
|
||||
/** SM-2 评级 → 间隔天数 & 掌握度变化映射 */
|
||||
export const REVIEW_INTERVALS: Record<
|
||||
ErrorBookReviewResult,
|
||||
{ interval: number; masteryDelta: number; streakDelta: number }
|
||||
> = {
|
||||
again: { interval: 1, masteryDelta: -1, streakDelta: -999 }, // 重来:重置连续答对
|
||||
hard: { interval: 2, masteryDelta: 0, streakDelta: 0 },
|
||||
good: { interval: 4, masteryDelta: 1, streakDelta: 1 },
|
||||
easy: { interval: 7, masteryDelta: 2, streakDelta: 1 },
|
||||
}
|
||||
|
||||
/** 间隔增长倍率 */
|
||||
export const INTERVAL_MULTIPLIERS: Record<ErrorBookReviewResult, number> = {
|
||||
again: 1,
|
||||
hard: 1.2,
|
||||
good: 1.5,
|
||||
easy: 2,
|
||||
}
|
||||
|
||||
/** 掌握度上限 */
|
||||
export const MAX_MASTERY_LEVEL = 5
|
||||
|
||||
/** 掌握度下限 */
|
||||
export const MIN_MASTERY_LEVEL = 0
|
||||
|
||||
/** 已掌握所需的连续答对次数 */
|
||||
export const MASTERED_REQUIRED_STREAK = 3
|
||||
|
||||
/** 已掌握所需的掌握度等级 */
|
||||
export const MASTERED_REQUIRED_MASTERY = 5
|
||||
|
||||
/**
|
||||
* 根据当前间隔和评级计算新间隔(指数增长)
|
||||
*
|
||||
* @param currentInterval 当前间隔天数
|
||||
* @param result 复习评级
|
||||
* @param reviewCount 已复习次数(首次复习使用基础间隔)
|
||||
* @returns 新的间隔天数
|
||||
*/
|
||||
export function calculateNewInterval(
|
||||
currentInterval: number,
|
||||
result: ErrorBookReviewResult,
|
||||
reviewCount: number
|
||||
): number {
|
||||
const base = REVIEW_INTERVALS[result]
|
||||
if (result === "again") return 1
|
||||
if (reviewCount === 0) return base.interval
|
||||
const multiplier = INTERVAL_MULTIPLIERS[result]
|
||||
return Math.max(base.interval, Math.round(currentInterval * multiplier))
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算新的掌握度(0-5)
|
||||
*
|
||||
* @param currentMastery 当前掌握度
|
||||
* @param result 复习评级
|
||||
* @param correctStreak 连续答对次数
|
||||
* @returns 新的掌握度
|
||||
*/
|
||||
export function calculateNewMastery(
|
||||
currentMastery: number,
|
||||
result: ErrorBookReviewResult,
|
||||
correctStreak: number
|
||||
): number {
|
||||
const delta = REVIEW_INTERVALS[result].masteryDelta
|
||||
const newMastery = Math.max(MIN_MASTERY_LEVEL, Math.min(MAX_MASTERY_LEVEL, currentMastery + delta))
|
||||
// 连续 3 次答对则标记为已掌握
|
||||
if (correctStreak >= MASTERED_REQUIRED_STREAK) return Math.max(newMastery, MASTERED_REQUIRED_MASTERY)
|
||||
return newMastery
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据掌握度推断状态
|
||||
*
|
||||
* @param masteryLevel 掌握度等级
|
||||
* @param correctStreak 连续答对次数
|
||||
* @returns 错题状态
|
||||
*/
|
||||
export function deriveStatus(masteryLevel: number, correctStreak: number): ErrorBookStatusValue {
|
||||
if (masteryLevel >= MASTERED_REQUIRED_MASTERY || correctStreak >= MASTERED_REQUIRED_STREAK) return "mastered"
|
||||
if (masteryLevel >= 1) return "learning"
|
||||
return "new"
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算下次复习时间
|
||||
*
|
||||
* @param intervalDays 间隔天数
|
||||
* @param now 当前时间(可选,用于测试注入)
|
||||
* @returns 下次复习时间(当天早上 9 点)
|
||||
*/
|
||||
export function calculateNextReviewAt(intervalDays: number, now: Date = new Date()): Date {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() + intervalDays)
|
||||
date.setHours(9, 0, 0, 0) // 早上 9 点复习
|
||||
return date
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算新的连续答对次数
|
||||
*
|
||||
* @param currentStreak 当前连续答对次数
|
||||
* @param result 复习评级
|
||||
* @returns 新的连续答对次数
|
||||
*/
|
||||
export function calculateNewCorrectStreak(
|
||||
currentStreak: number,
|
||||
result: ErrorBookReviewResult
|
||||
): number {
|
||||
const streakDelta = REVIEW_INTERVALS[result].streakDelta
|
||||
if (streakDelta < 0) return 0 // again 重置
|
||||
return currentStreak + streakDelta
|
||||
}
|
||||
|
||||
/**
|
||||
* SM-2 算法完整计算结果
|
||||
*/
|
||||
export interface Sm2CalculationResult {
|
||||
/** 新的间隔天数 */
|
||||
newInterval: number
|
||||
/** 新的掌握度 */
|
||||
newMasteryLevel: number
|
||||
/** 新的连续答对次数 */
|
||||
newCorrectStreak: number
|
||||
/** 推导出的新状态 */
|
||||
newStatus: ErrorBookStatusValue
|
||||
/** 下次复习时间 */
|
||||
nextReviewAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性计算所有 SM-2 相关字段
|
||||
*
|
||||
* @param params 当前错题状态参数
|
||||
* @param result 复习评级
|
||||
* @param now 当前时间(可选,用于测试注入)
|
||||
* @returns 完整计算结果
|
||||
*/
|
||||
export function calculateSm2Result(
|
||||
params: {
|
||||
currentInterval: number
|
||||
currentMastery: number
|
||||
currentStreak: number
|
||||
reviewCount: number
|
||||
},
|
||||
result: ErrorBookReviewResult,
|
||||
now: Date = new Date()
|
||||
): Sm2CalculationResult {
|
||||
const newCorrectStreak = calculateNewCorrectStreak(params.currentStreak, result)
|
||||
const newInterval = calculateNewInterval(params.currentInterval, result, params.reviewCount)
|
||||
const newMasteryLevel = calculateNewMastery(params.currentMastery, result, newCorrectStreak)
|
||||
const newStatus = deriveStatus(newMasteryLevel, newCorrectStreak)
|
||||
const nextReviewAt = calculateNextReviewAt(newInterval, now)
|
||||
|
||||
return {
|
||||
newInterval,
|
||||
newMasteryLevel,
|
||||
newCorrectStreak,
|
||||
newStatus,
|
||||
nextReviewAt,
|
||||
}
|
||||
}
|
||||
196
src/modules/error-book/types.ts
Normal file
196
src/modules/error-book/types.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
|
||||
import type { ErrorBookSourceType, ErrorBookStatus, ErrorBookReviewResult } from "./schema"
|
||||
|
||||
export type ErrorBookSourceTypeValue = ErrorBookSourceType
|
||||
export type ErrorBookStatusValue = ErrorBookStatus
|
||||
export type ErrorBookReviewResultValue = ErrorBookReviewResult
|
||||
|
||||
/** 错题来源 → Badge variant 映射 */
|
||||
export const ERROR_BOOK_SOURCE_VARIANT: StatusVariantMap<ErrorBookSourceTypeValue> = {
|
||||
exam: "default",
|
||||
homework: "secondary",
|
||||
manual: "outline",
|
||||
}
|
||||
|
||||
/** 错题来源 → 展示文本映射 */
|
||||
export const ERROR_BOOK_SOURCE_LABEL: StatusLabelMap<ErrorBookSourceTypeValue> = {
|
||||
exam: "考试",
|
||||
homework: "作业",
|
||||
manual: "手动添加",
|
||||
}
|
||||
|
||||
/** 错题状态 → Badge variant 映射 */
|
||||
export const ERROR_BOOK_STATUS_VARIANT: StatusVariantMap<ErrorBookStatusValue> = {
|
||||
new: "secondary",
|
||||
learning: "default",
|
||||
mastered: "outline",
|
||||
archived: "outline",
|
||||
}
|
||||
|
||||
/** 错题状态 → 展示文本映射 */
|
||||
export const ERROR_BOOK_STATUS_LABEL: StatusLabelMap<ErrorBookStatusValue> = {
|
||||
new: "待学习",
|
||||
learning: "学习中",
|
||||
mastered: "已掌握",
|
||||
archived: "已归档",
|
||||
}
|
||||
|
||||
/** 复习自评结果 → Badge variant 映射 */
|
||||
export const REVIEW_RESULT_VARIANT: StatusVariantMap<ErrorBookReviewResultValue> = {
|
||||
again: "destructive",
|
||||
hard: "secondary",
|
||||
good: "default",
|
||||
easy: "outline",
|
||||
}
|
||||
|
||||
/** 复习自评结果 → 展示文本映射 */
|
||||
export const REVIEW_RESULT_LABEL: StatusLabelMap<ErrorBookReviewResultValue> = {
|
||||
again: "重来",
|
||||
hard: "困难",
|
||||
good: "良好",
|
||||
easy: "简单",
|
||||
}
|
||||
|
||||
/** 常见错误原因标签 */
|
||||
export const COMMON_ERROR_TAGS = [
|
||||
"概念不清",
|
||||
"计算错误",
|
||||
"粗心大意",
|
||||
"审题不清",
|
||||
"方法不当",
|
||||
"记忆错误",
|
||||
"时间不足",
|
||||
] as const
|
||||
|
||||
/** 错题本列表条目 */
|
||||
export interface ErrorBookItem {
|
||||
id: string
|
||||
studentId: string
|
||||
questionId: string
|
||||
sourceType: ErrorBookSourceTypeValue
|
||||
sourceId: string | null
|
||||
studentAnswer: unknown
|
||||
correctAnswer: unknown
|
||||
subjectId: string | null
|
||||
knowledgePointIds: string[] | null
|
||||
status: ErrorBookStatusValue
|
||||
masteryLevel: number
|
||||
nextReviewAt: Date | null
|
||||
reviewInterval: number
|
||||
reviewCount: number
|
||||
correctStreak: number
|
||||
note: string | null
|
||||
errorTags: string[] | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
/** 关联题目(联表查询) */
|
||||
question: {
|
||||
id: string
|
||||
content: unknown
|
||||
type: string
|
||||
difficulty: number | null
|
||||
} | null
|
||||
/** 关联学科名称 */
|
||||
subjectName: string | null
|
||||
}
|
||||
|
||||
/** 错题本列表查询参数 */
|
||||
export type GetErrorBookItemsParams = {
|
||||
studentId: string
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: ErrorBookStatusValue
|
||||
sourceType?: ErrorBookSourceTypeValue
|
||||
subjectId?: string
|
||||
/** 仅查询到期需要复习的条目 */
|
||||
dueOnly?: boolean
|
||||
}
|
||||
|
||||
/** 错题本列表结果 */
|
||||
export type ErrorBookListResult = {
|
||||
data: ErrorBookItem[]
|
||||
meta: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
/** 错题详情(含复习历史) */
|
||||
export interface ErrorBookItemDetail extends ErrorBookItem {
|
||||
reviews: ErrorBookReviewRecord[]
|
||||
}
|
||||
|
||||
/** 复习记录 */
|
||||
export interface ErrorBookReviewRecord {
|
||||
id: string
|
||||
result: ErrorBookReviewResultValue
|
||||
reviewedAt: Date
|
||||
newInterval: number | null
|
||||
newMasteryLevel: number | null
|
||||
}
|
||||
|
||||
/** 错题本统计概览 */
|
||||
export interface ErrorBookStats {
|
||||
totalCount: number
|
||||
newCount: number
|
||||
learningCount: number
|
||||
masteredCount: number
|
||||
archivedCount: number
|
||||
dueReviewCount: number
|
||||
masteredRate: number
|
||||
}
|
||||
|
||||
/** 知识点薄弱度统计 */
|
||||
export interface KnowledgePointWeakness {
|
||||
knowledgePointId: string
|
||||
knowledgePointName: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
totalCount: number
|
||||
/** 掌握率 0-1 */
|
||||
masteryRate: number
|
||||
}
|
||||
|
||||
/** 学科错题分布 */
|
||||
export interface SubjectErrorDistribution {
|
||||
subjectId: string | null
|
||||
subjectName: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
}
|
||||
|
||||
/** 班级学生错题统计(教师视图) */
|
||||
export interface StudentErrorBookSummary {
|
||||
studentId: string
|
||||
totalCount: number
|
||||
newCount: number
|
||||
learningCount: number
|
||||
masteredCount: number
|
||||
dueReviewCount: number
|
||||
masteredRate: number
|
||||
lastActivityAt: Date | null
|
||||
}
|
||||
|
||||
/** 班级错题统计(教师视图) */
|
||||
export interface ClassErrorBookStats {
|
||||
totalStudents: number
|
||||
studentsWithErrorBook: number
|
||||
totalErrorItems: number
|
||||
averageErrorPerStudent: number
|
||||
averageMasteryRate: number
|
||||
topWeakKnowledgePoints: KnowledgePointWeakness[]
|
||||
subjectDistribution: SubjectErrorDistribution[]
|
||||
topStudents: StudentErrorBookSummary[]
|
||||
}
|
||||
|
||||
/** 错题趋势数据点 */
|
||||
export interface ErrorBookTrendPoint {
|
||||
date: string
|
||||
addedCount: number
|
||||
masteredCount: number
|
||||
reviewedCount: number
|
||||
}
|
||||
Reference in New Issue
Block a user