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:
19
src/app/(dashboard)/admin/error-book/error.tsx
Normal file
19
src/app/(dashboard)/admin/error-book/error.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function AdminErrorBookError() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="加载全校错题分析失败"
|
||||||
|
description="发生了一些错误,请刷新页面重试。"
|
||||||
|
action={{ label: "刷新页面", onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/app/(dashboard)/admin/error-book/loading.tsx
Normal file
23
src/app/(dashboard)/admin/error-book/loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function AdminErrorBookLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[300px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[120px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[300px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-[400px] w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/app/(dashboard)/admin/error-book/page.tsx
Normal file
113
src/app/(dashboard)/admin/error-book/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStudentErrorBookSummaries,
|
||||||
|
getTopWrongQuestionsByStudentIds,
|
||||||
|
getKnowledgePointWeakness,
|
||||||
|
getSubjectErrorDistribution,
|
||||||
|
getStudentNameMap,
|
||||||
|
getAllStudentIds,
|
||||||
|
} from "@/modules/error-book/data-access"
|
||||||
|
import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview"
|
||||||
|
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminErrorBookPage(): Promise<JSX.Element> {
|
||||||
|
const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ)
|
||||||
|
|
||||||
|
if (ctx.dataScope.type !== "all") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">全校错题分析</h1>
|
||||||
|
<p className="text-muted-foreground">查看全校学生的错题统计与薄弱知识点。</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="权限不足"
|
||||||
|
description="您没有权限查看全校错题分析数据。"
|
||||||
|
className="h-[360px] bg-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 data-access 层查询所有学生 ID(遵循三层架构,app 层不直接访问 DB)
|
||||||
|
const studentIds = await getAllStudentIds()
|
||||||
|
|
||||||
|
if (studentIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">全校错题分析</h1>
|
||||||
|
<p className="text-muted-foreground">查看全校学生的错题统计与薄弱知识点。</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="暂无学生数据"
|
||||||
|
description="系统中还没有学生用户,无法查看错题分析。"
|
||||||
|
className="h-[360px] bg-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制查询数量,避免性能问题(取最近活跃的 500 名学生)
|
||||||
|
const limitedStudentIds = studentIds.slice(0, 500)
|
||||||
|
|
||||||
|
const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([
|
||||||
|
getStudentErrorBookSummaries(limitedStudentIds),
|
||||||
|
getTopWrongQuestionsByStudentIds(limitedStudentIds, 10),
|
||||||
|
getKnowledgePointWeakness(limitedStudentIds, 10),
|
||||||
|
getSubjectErrorDistribution(limitedStudentIds),
|
||||||
|
getStudentNameMap(limitedStudentIds),
|
||||||
|
])
|
||||||
|
|
||||||
|
const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0)
|
||||||
|
const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0)
|
||||||
|
const averageMasteryRate = studentsWithErrorBook.length > 0
|
||||||
|
? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const sortedSummaries = [...summaries]
|
||||||
|
.filter((s) => s.totalCount > 0)
|
||||||
|
.sort((a, b) => b.totalCount - a.totalCount)
|
||||||
|
.slice(0, 50)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">全校错题分析</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
全校错题统计与薄弱知识点分析,辅助教学决策。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClassErrorBookOverview
|
||||||
|
totalStudents={studentIds.length}
|
||||||
|
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||||
|
totalErrorItems={totalErrorItems}
|
||||||
|
averageMasteryRate={averageMasteryRate}
|
||||||
|
topWeakKnowledgePoints={weakKps}
|
||||||
|
subjectDistribution={subjectDist}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">错题最多的学生 Top 50</h2>
|
||||||
|
<StudentErrorTable
|
||||||
|
students={sortedSummaries}
|
||||||
|
studentNames={nameMap}
|
||||||
|
basePath="/admin/error-book"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TopWrongQuestions questions={topWrongQuestions} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/app/(dashboard)/parent/error-book/error.tsx
Normal file
19
src/app/(dashboard)/parent/error-book/error.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BookX } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function ParentErrorBookError() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookX}
|
||||||
|
title="加载子女错题本失败"
|
||||||
|
description="发生了一些错误,请刷新页面重试。"
|
||||||
|
action={{ label: "刷新页面", onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/app/(dashboard)/parent/error-book/loading.tsx
Normal file
18
src/app/(dashboard)/parent/error-book/loading.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function ParentErrorBookLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[180px]" />
|
||||||
|
<Skeleton className="h-4 w-[280px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[120px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-[300px] w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
src/app/(dashboard)/parent/error-book/page.tsx
Normal file
138
src/app/(dashboard)/parent/error-book/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
import { Users } from "lucide-react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Progress } from "@/shared/components/ui/progress"
|
||||||
|
import { formatNumber } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getErrorBookStats,
|
||||||
|
getStudentNameMap,
|
||||||
|
getTopWrongQuestionsByStudentIds,
|
||||||
|
getKnowledgePointWeakness,
|
||||||
|
} from "@/modules/error-book/data-access"
|
||||||
|
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
|
||||||
|
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||||
|
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||||
|
|
||||||
|
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">子女错题本</h1>
|
||||||
|
<p className="text-muted-foreground">查看子女的错题情况与学习进度。</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="暂无子女关联"
|
||||||
|
description="您的账号尚未关联子女,请联系学校管理员进行关联。"
|
||||||
|
className="h-[360px] bg-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenIds = ctx.dataScope.childrenIds
|
||||||
|
|
||||||
|
const [nameMap, ...childStatsList] = await Promise.all([
|
||||||
|
getStudentNameMap(childrenIds),
|
||||||
|
...childrenIds.map((id) => getErrorBookStats(id)),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 汇总所有子女的错题
|
||||||
|
const [topWrongQuestions, weakKps] = await Promise.all([
|
||||||
|
getTopWrongQuestionsByStudentIds(childrenIds, 5),
|
||||||
|
getKnowledgePointWeakness(childrenIds, 5),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">子女错题本</h1>
|
||||||
|
<p className="text-muted-foreground">查看子女的错题情况与学习进度。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{childrenIds.length === 1 ? (
|
||||||
|
// 单子女:直接展示统计卡片
|
||||||
|
<ErrorBookStatsCards stats={childStatsList[0]} />
|
||||||
|
) : (
|
||||||
|
// 多子女:每个子女一张卡片
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{childrenIds.map((childId, idx) => {
|
||||||
|
const stats = childStatsList[idx]
|
||||||
|
const name = nameMap.get(childId) ?? "未知"
|
||||||
|
return (
|
||||||
|
<Card key={childId}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
|
<span>{name}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{formatNumber(stats.masteredRate * 100, 0)}% 掌握
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">错题总数</div>
|
||||||
|
<div className="text-lg font-bold">{stats.totalCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">待复习</div>
|
||||||
|
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">
|
||||||
|
{stats.dueReviewCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">待学习</div>
|
||||||
|
<div className="font-medium text-blue-600 dark:text-blue-400">{stats.newCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">已掌握</div>
|
||||||
|
<div className="font-medium text-emerald-600 dark:text-emerald-400">{stats.masteredCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={stats.masteredRate * 100} className="h-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 薄弱知识点 */}
|
||||||
|
{weakKps.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">薄弱知识点</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weakKps.map((kp) => (
|
||||||
|
<div key={kp.knowledgePointId} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{kp.knowledgePointName}</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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TopWrongQuestions questions={topWrongQuestions} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/app/(dashboard)/student/error-book/error.tsx
Normal file
19
src/app/(dashboard)/student/error-book/error.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BookX } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function StudentErrorBookError() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookX}
|
||||||
|
title="加载错题本失败"
|
||||||
|
description="发生了一些错误,请刷新页面重试。如果问题持续,请联系管理员。"
|
||||||
|
action={{ label: "刷新页面", onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/app/(dashboard)/student/error-book/loading.tsx
Normal file
30
src/app/(dashboard)/student/error-book/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function StudentErrorBookLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[180px]" />
|
||||||
|
<Skeleton className="h-4 w-[280px]" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[120px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[180px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/app/(dashboard)/teacher/error-book/error.tsx
Normal file
19
src/app/(dashboard)/teacher/error-book/error.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function TeacherErrorBookError() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="加载错题分析失败"
|
||||||
|
description="发生了一些错误,请刷新页面重试。"
|
||||||
|
action={{ label: "刷新页面", onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/app/(dashboard)/teacher/error-book/loading.tsx
Normal file
26
src/app/(dashboard)/teacher/error-book/loading.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function TeacherErrorBookLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[180px]" />
|
||||||
|
<Skeleton className="h-4 w-[300px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[120px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, idx) => (
|
||||||
|
<Skeleton key={idx} className="h-[300px] w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-[400px] w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
src/app/(dashboard)/teacher/error-book/page.tsx
Normal file
119
src/app/(dashboard)/teacher/error-book/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStudentErrorBookSummaries,
|
||||||
|
getTopWrongQuestionsByStudentIds,
|
||||||
|
getKnowledgePointWeakness,
|
||||||
|
getSubjectErrorDistribution,
|
||||||
|
getStudentNameMap,
|
||||||
|
} from "@/modules/error-book/data-access"
|
||||||
|
import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview"
|
||||||
|
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function TeacherErrorBookPage(): Promise<JSX.Element> {
|
||||||
|
const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ)
|
||||||
|
|
||||||
|
// 教师的 dataScope 为 class_taught,年级主任/教研组长为 grade_managed,管理员为 all
|
||||||
|
const classIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
|
||||||
|
const gradeIds = ctx.dataScope.type === "grade_managed" ? ctx.dataScope.gradeIds : []
|
||||||
|
|
||||||
|
if (classIds.length === 0 && gradeIds.length === 0 && ctx.dataScope.type !== "all") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||||
|
<p className="text-muted-foreground">查看班级学生的错题统计与薄弱知识点。</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="暂无可查看的班级"
|
||||||
|
description="您还未被分配到任何班级,无法查看错题分析数据。"
|
||||||
|
className="h-[360px] bg-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年级主任/教研组长:先根据 gradeIds 查询班级,再查询学生
|
||||||
|
let targetClassIds = classIds
|
||||||
|
if (gradeIds.length > 0) {
|
||||||
|
const gradeClassIds = await getClassIdsByGradeIds(gradeIds)
|
||||||
|
targetClassIds = [...new Set([...classIds, ...gradeClassIds])]
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentIds = await getStudentIdsByClassIds(targetClassIds)
|
||||||
|
|
||||||
|
if (studentIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||||
|
<p className="text-muted-foreground">查看班级学生的错题统计与薄弱知识点。</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title="班级暂无学生"
|
||||||
|
description="班级中没有学生,无法查看错题分析数据。"
|
||||||
|
className="h-[360px] bg-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行查询所有统计数据
|
||||||
|
const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([
|
||||||
|
getStudentErrorBookSummaries(studentIds),
|
||||||
|
getTopWrongQuestionsByStudentIds(studentIds, 10),
|
||||||
|
getKnowledgePointWeakness(studentIds, 10),
|
||||||
|
getSubjectErrorDistribution(studentIds),
|
||||||
|
getStudentNameMap(studentIds),
|
||||||
|
])
|
||||||
|
|
||||||
|
const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0)
|
||||||
|
const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0)
|
||||||
|
const averageMasteryRate = studentsWithErrorBook.length > 0
|
||||||
|
? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 按错题数降序排列
|
||||||
|
const sortedSummaries = [...summaries].sort((a, b) => b.totalCount - a.totalCount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
查看班级学生的错题统计与薄弱知识点,辅助精准教学。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClassErrorBookOverview
|
||||||
|
totalStudents={studentIds.length}
|
||||||
|
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||||
|
totalErrorItems={totalErrorItems}
|
||||||
|
averageMasteryRate={averageMasteryRate}
|
||||||
|
topWeakKnowledgePoints={weakKps}
|
||||||
|
subjectDistribution={subjectDist}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">学生错题详情</h2>
|
||||||
|
<StudentErrorTable
|
||||||
|
students={sortedSummaries}
|
||||||
|
studentNames={nameMap}
|
||||||
|
basePath="/teacher/error-book"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TopWrongQuestions questions={topWrongQuestions} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
101
src/shared/i18n/messages/en/error-book.json
Normal file
101
src/shared/i18n/messages/en/error-book.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"title": "Error Book",
|
||||||
|
"description": "Automatically collect wrong answers from exams and homework, review scientifically",
|
||||||
|
"stats": {
|
||||||
|
"total": "Total Errors",
|
||||||
|
"new": "New",
|
||||||
|
"learning": "Learning",
|
||||||
|
"mastered": "Mastered",
|
||||||
|
"dueReview": "Due Review",
|
||||||
|
"masteredRate": "Mastery Rate"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"new": "New",
|
||||||
|
"learning": "Learning",
|
||||||
|
"mastered": "Mastered",
|
||||||
|
"archived": "Archived"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"exam": "Exam",
|
||||||
|
"homework": "Homework",
|
||||||
|
"manual": "Manual"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"again": "Again",
|
||||||
|
"hard": "Hard",
|
||||||
|
"good": "Good",
|
||||||
|
"easy": "Easy",
|
||||||
|
"againDesc": "Don't know, review tomorrow",
|
||||||
|
"hardDesc": "Barely correct, review in 2 days",
|
||||||
|
"goodDesc": "Correct, review in 4 days",
|
||||||
|
"easyDesc": "Easy, review in 7 days"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "Add Manually",
|
||||||
|
"viewDetail": "View Details",
|
||||||
|
"saveNote": "Save Note",
|
||||||
|
"archive": "Archive",
|
||||||
|
"delete": "Delete",
|
||||||
|
"collect": "Collect Errors"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"question": "Select Question",
|
||||||
|
"note": "Study Note",
|
||||||
|
"errorTags": "Error Reason Tags",
|
||||||
|
"masteryLevel": "Mastery Level",
|
||||||
|
"reviewCount": "Review Count",
|
||||||
|
"nextReview": "Next Review",
|
||||||
|
"createdAt": "Added At"
|
||||||
|
},
|
||||||
|
"errorTags": {
|
||||||
|
"concept": "Concept Gap",
|
||||||
|
"calculation": "Calculation Error",
|
||||||
|
"careless": "Careless",
|
||||||
|
"misread": "Misread Question",
|
||||||
|
"method": "Wrong Method",
|
||||||
|
"memory": "Memory Error",
|
||||||
|
"time": "Out of Time"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Error book is empty",
|
||||||
|
"description": "Wrong answers from exams and homework will be collected here automatically. You can also add manually."
|
||||||
|
},
|
||||||
|
"teacher": {
|
||||||
|
"title": "Error Analysis",
|
||||||
|
"description": "View class error statistics and weak knowledge points",
|
||||||
|
"coverage": "Student Coverage",
|
||||||
|
"totalErrors": "Total Errors",
|
||||||
|
"avgMastery": "Avg Mastery Rate",
|
||||||
|
"weakPoints": "Weak Knowledge Points",
|
||||||
|
"subjectDist": "Subject Distribution",
|
||||||
|
"studentDetail": "Student Error Details",
|
||||||
|
"topWrong": "Top Wrong Questions",
|
||||||
|
"noClass": "No classes assigned",
|
||||||
|
"noStudent": "No students in class"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"title": "Child Error Book",
|
||||||
|
"description": "View your child's error statistics and learning progress",
|
||||||
|
"noChild": "No children linked"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "School-wide Error Analysis",
|
||||||
|
"description": "School-wide error statistics and weak point analysis"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"added": "Error added",
|
||||||
|
"noteSaved": "Note saved",
|
||||||
|
"reviewRecorded": "Review recorded",
|
||||||
|
"archived": "Error archived",
|
||||||
|
"deleted": "Error deleted",
|
||||||
|
"collected": "Collected {{count}} errors",
|
||||||
|
"noNewErrors": "No new errors to collect",
|
||||||
|
"addFailed": "Failed to add error",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"archiveFailed": "Archive failed",
|
||||||
|
"collectFailed": "Failed to collect errors",
|
||||||
|
"notFound": "Error not found or access denied",
|
||||||
|
"selectQuestion": "Please select a question"
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/shared/i18n/messages/zh-CN/error-book.json
Normal file
101
src/shared/i18n/messages/zh-CN/error-book.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"title": "错题本",
|
||||||
|
"description": "自动收录考试与作业中的错题,科学复习,攻克薄弱点",
|
||||||
|
"stats": {
|
||||||
|
"total": "错题总数",
|
||||||
|
"new": "待学习",
|
||||||
|
"learning": "学习中",
|
||||||
|
"mastered": "已掌握",
|
||||||
|
"dueReview": "待复习",
|
||||||
|
"masteredRate": "掌握率"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"new": "待学习",
|
||||||
|
"learning": "学习中",
|
||||||
|
"mastered": "已掌握",
|
||||||
|
"archived": "已归档"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"exam": "考试",
|
||||||
|
"homework": "作业",
|
||||||
|
"manual": "手动添加"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"again": "重来",
|
||||||
|
"hard": "困难",
|
||||||
|
"good": "良好",
|
||||||
|
"easy": "简单",
|
||||||
|
"againDesc": "完全不会,明天再复习",
|
||||||
|
"hardDesc": "勉强答对,2 天后复习",
|
||||||
|
"goodDesc": "正常答对,4 天后复习",
|
||||||
|
"easyDesc": "轻松答对,7 天后复习"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "手动添加",
|
||||||
|
"viewDetail": "查看详情",
|
||||||
|
"saveNote": "保存笔记",
|
||||||
|
"archive": "归档",
|
||||||
|
"delete": "删除",
|
||||||
|
"collect": "采集错题"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"question": "选择题目",
|
||||||
|
"note": "学习笔记",
|
||||||
|
"errorTags": "错误原因标签",
|
||||||
|
"masteryLevel": "掌握度",
|
||||||
|
"reviewCount": "复习次数",
|
||||||
|
"nextReview": "下次复习",
|
||||||
|
"createdAt": "添加时间"
|
||||||
|
},
|
||||||
|
"errorTags": {
|
||||||
|
"concept": "概念不清",
|
||||||
|
"calculation": "计算错误",
|
||||||
|
"careless": "粗心大意",
|
||||||
|
"misread": "审题不清",
|
||||||
|
"method": "方法不当",
|
||||||
|
"memory": "记忆错误",
|
||||||
|
"time": "时间不足"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "错题本为空",
|
||||||
|
"description": "完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
|
||||||
|
},
|
||||||
|
"teacher": {
|
||||||
|
"title": "错题分析",
|
||||||
|
"description": "查看班级学生的错题统计与薄弱知识点,辅助精准教学",
|
||||||
|
"coverage": "覆盖学生",
|
||||||
|
"totalErrors": "错题总数",
|
||||||
|
"avgMastery": "平均掌握率",
|
||||||
|
"weakPoints": "薄弱知识点",
|
||||||
|
"subjectDist": "学科错题分布",
|
||||||
|
"studentDetail": "学生错题详情",
|
||||||
|
"topWrong": "高频错题",
|
||||||
|
"noClass": "暂无可查看的班级",
|
||||||
|
"noStudent": "班级暂无学生"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"title": "子女错题本",
|
||||||
|
"description": "查看子女的错题情况与学习进度",
|
||||||
|
"noChild": "暂无子女关联"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "全校错题分析",
|
||||||
|
"description": "全校错题统计与薄弱知识点分析,辅助教学决策"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"added": "错题已添加",
|
||||||
|
"noteSaved": "笔记已保存",
|
||||||
|
"reviewRecorded": "复习结果已记录",
|
||||||
|
"archived": "错题已归档",
|
||||||
|
"deleted": "错题已删除",
|
||||||
|
"collected": "已采集 {{count}} 道错题",
|
||||||
|
"noNewErrors": "没有新的错题需要采集",
|
||||||
|
"addFailed": "添加错题失败",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"archiveFailed": "归档失败",
|
||||||
|
"collectFailed": "采集错题失败",
|
||||||
|
"notFound": "错题不存在或无权访问",
|
||||||
|
"selectQuestion": "请选择题目"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user