feat(error-book): update components, actions, data-access, and add analytics
- Add data-access-analytics for error book analytics queries - Update actions, data-access for improved error book operations - Update components: add-error-book-dialog, analytics-stats-cards, chapter-weakness-chart, class-error-bar-chart, class-filter, error-book-detail-dialog, error-book-filters, error-book-item-card, error-book-list, error-book-stats-cards, grouped-student-error-table, knowledge-point-weakness-chart, review-buttons, subject-distribution-chart, subject-tabs, top-wrong-questions - Remove class-error-overview (replaced by new components)
This commit is contained in:
@@ -58,16 +58,14 @@ export async function getErrorBookItemsAction(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const statusParse = z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status)
|
||||
const status = params.status && params.status !== "all" && statusParse.success
|
||||
? statusParse.data
|
||||
: undefined
|
||||
|
||||
const sourceType = params.sourceType && params.sourceType !== "all"
|
||||
? (z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType).success
|
||||
? (params.sourceType as "exam" | "homework" | "manual")
|
||||
: undefined)
|
||||
const sourceTypeParse = z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType)
|
||||
const sourceType = params.sourceType && params.sourceType !== "all" && sourceTypeParse.success
|
||||
? sourceTypeParse.data
|
||||
: undefined
|
||||
|
||||
const data = await getErrorBookItems({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useTransition, useEffect } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,53 +24,44 @@ import {
|
||||
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() {
|
||||
interface QuestionOption {
|
||||
id: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface AddErrorBookDialogProps {
|
||||
/** 预加载的题目选项(若提供则直接使用,不再调用 onLoadQuestions) */
|
||||
questionOptions?: QuestionOption[]
|
||||
/** 懒加载题目选项的回调(对话框打开时调用) */
|
||||
onLoadQuestions?: () => Promise<QuestionOption[]>
|
||||
}
|
||||
|
||||
export function AddErrorBookDialog({
|
||||
questionOptions: questionOptionsProp,
|
||||
onLoadQuestions,
|
||||
}: AddErrorBookDialogProps) {
|
||||
const t = useTranslations("error-book")
|
||||
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
|
||||
}>>([])
|
||||
const [loadedOptions, setLoadedOptions] = useState<QuestionOption[]>([])
|
||||
|
||||
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 "题目"
|
||||
}
|
||||
const questionOptions = questionOptionsProp ?? loadedOptions
|
||||
|
||||
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),
|
||||
}))
|
||||
)
|
||||
}
|
||||
if (open && onLoadQuestions && loadedOptions.length === 0 && !questionOptionsProp) {
|
||||
onLoadQuestions()
|
||||
.then((options) => {
|
||||
setLoadedOptions(options)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [open, questionOptions.length])
|
||||
}, [open, onLoadQuestions, loadedOptions.length, questionOptionsProp])
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
setErrorTags((prev) =>
|
||||
@@ -79,7 +71,7 @@ export function AddErrorBookDialog() {
|
||||
|
||||
function handleSubmit() {
|
||||
if (!questionId) {
|
||||
toast.error("请选择题目")
|
||||
toast.error(t("messages.selectQuestion"))
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
@@ -90,13 +82,13 @@ export function AddErrorBookDialog() {
|
||||
)
|
||||
const res = await createErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "已添加")
|
||||
toast.success(res.message ?? t("messages.addedShort"))
|
||||
setOpen(false)
|
||||
setQuestionId("")
|
||||
setNote("")
|
||||
setErrorTags([])
|
||||
} else {
|
||||
toast.error(res.message ?? "添加失败")
|
||||
toast.error(res.message ?? t("messages.addFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -106,23 +98,23 @@ export function AddErrorBookDialog() {
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" data-icon="inline-start" />
|
||||
手动添加
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加错题</DialogTitle>
|
||||
<DialogTitle>{t("addDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
从题库中选择题目,添加到你的错题本。你也可以在完成作业/考试后自动采集。
|
||||
{t("addDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="question">选择题目</Label>
|
||||
<Label htmlFor="question">{t("addDialog.selectQuestion")}</Label>
|
||||
<Select value={questionId} onValueChange={setQuestionId}>
|
||||
<SelectTrigger id="question">
|
||||
<SelectValue placeholder="从题库中选择..." />
|
||||
<SelectValue placeholder={t("addDialog.selectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{questionOptions.map((q) => (
|
||||
@@ -135,18 +127,18 @@ export function AddErrorBookDialog() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">学习笔记(可选)</Label>
|
||||
<Label htmlFor="note">{t("addDialog.noteLabel")}</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="记录错误原因、解题思路..."
|
||||
placeholder={t("addDialog.notePlaceholder")}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>错误原因标签</Label>
|
||||
<Label>{t("addDialog.errorTagsLabel")}</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COMMON_ERROR_TAGS.map((tag) => (
|
||||
<Button
|
||||
@@ -165,10 +157,10 @@ export function AddErrorBookDialog() {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button disabled={isPending || !questionId} onClick={handleSubmit}>
|
||||
{isPending ? "添加中..." : "添加"}
|
||||
{isPending ? t("actions.adding") : t("actions.add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
@@ -26,43 +29,52 @@ export function AnalyticsStatsCards({
|
||||
knowledgePointCount,
|
||||
className,
|
||||
}: AnalyticsStatsCardsProps) {
|
||||
const t = useTranslations("error-book")
|
||||
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: "覆盖学生",
|
||||
label: t("analyticsStats.coverage"),
|
||||
value: studentsWithErrorBook,
|
||||
sub: `/ ${totalStudents} 人`,
|
||||
sub: t("analyticsStats.coverageSub", { total: totalStudents }),
|
||||
icon: BookOpen,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bg: "bg-blue-50 dark:bg-blue-950/30",
|
||||
},
|
||||
{
|
||||
label: "错题总数",
|
||||
label: t("analyticsStats.totalErrors"),
|
||||
value: totalErrorItems,
|
||||
sub: `人均 ${(totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)} 题`,
|
||||
sub: t("analyticsStats.totalErrorsSub", { avg }),
|
||||
icon: TrendingUp,
|
||||
color: "text-rose-600 dark:text-rose-400",
|
||||
bg: "bg-rose-50 dark:bg-rose-950/30",
|
||||
},
|
||||
{
|
||||
label: "平均掌握率",
|
||||
label: t("analyticsStats.avgMastery"),
|
||||
value: `${Math.round(averageMasteryRate * 100)}%`,
|
||||
sub: averageMasteryRate >= 0.6 ? "整体良好" : "需加强",
|
||||
sub: averageMasteryRate >= 0.6
|
||||
? t("analyticsStats.avgMasteryGood")
|
||||
: t("analyticsStats.avgMasteryNeedImprove"),
|
||||
icon: CheckCircle2,
|
||||
color: "text-emerald-600 dark:text-emerald-400",
|
||||
bg: "bg-emerald-50 dark:bg-emerald-950/30",
|
||||
},
|
||||
{
|
||||
label: "待复习",
|
||||
label: t("analyticsStats.dueReview"),
|
||||
value: dueReviewCount,
|
||||
sub: dueReviewCount > 0 ? "需要关注" : "无到期",
|
||||
sub: dueReviewCount > 0
|
||||
? t("analyticsStats.dueReviewNeedAttention")
|
||||
: t("analyticsStats.dueReviewNone"),
|
||||
icon: Clock,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bg: "bg-amber-50 dark:bg-amber-950/30",
|
||||
},
|
||||
{
|
||||
label: "涉及知识点",
|
||||
label: t("analyticsStats.knowledgePoints"),
|
||||
value: knowledgePointCount ?? 0,
|
||||
sub: knowledgePointCount && knowledgePointCount > 5 ? "范围较广" : "集中",
|
||||
sub: knowledgePointCount && knowledgePointCount > 5
|
||||
? t("analyticsStats.knowledgePointsWide")
|
||||
: t("analyticsStats.knowledgePointsFocused"),
|
||||
icon: Brain,
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bg: "bg-purple-50 dark:bg-purple-950/30",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -18,12 +19,36 @@ interface ChapterWeaknessChartProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ChapterChartPayload {
|
||||
name: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
knowledgePointCount: number
|
||||
topKps: Array<{ knowledgePointName: string; errorCount: number }>
|
||||
}
|
||||
|
||||
function isChapterChartPayload(v: unknown): v is ChapterChartPayload {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
|
||||
const obj = v as Record<string, unknown>
|
||||
return (
|
||||
typeof obj.name === "string" &&
|
||||
typeof obj.errorCount === "number" &&
|
||||
typeof obj.masteredCount === "number" &&
|
||||
typeof obj.masteryRate === "number" &&
|
||||
typeof obj.knowledgePointCount === "number"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节薄弱度图表(哪些课在错)
|
||||
* 横向柱状图,按错题数降序
|
||||
* 每个柱子可展开显示该章节下错得最多的知识点
|
||||
*/
|
||||
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
@@ -37,7 +62,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
errorCount: {
|
||||
label: "错题数",
|
||||
label: t("chapterChart.errorCount"),
|
||||
color: "var(--color-chart-2)",
|
||||
},
|
||||
}
|
||||
@@ -45,10 +70,14 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">章节错题分布(哪些课在错)</CardTitle>
|
||||
<CardTitle className="text-base">{t("chapterChart.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ChartContainer config={chartConfig} className="h-[300px] w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[300px] w-full"
|
||||
aria-label={t("chapterChart.title")}
|
||||
>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
@@ -71,29 +100,32 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
|
||||
<ChartTooltipContent
|
||||
className="w-[280px]"
|
||||
formatter={(payload: unknown) => {
|
||||
const p = payload as unknown as {
|
||||
name: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
knowledgePointCount: number
|
||||
topKps: Array<{ knowledgePointName: string; errorCount: number }>
|
||||
}
|
||||
if (!isChapterChartPayload(payload)) return null
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="font-medium">{payload.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
错题数:<span className="font-medium text-foreground">{p.errorCount}</span>
|
||||
<span className="ml-2">已掌握:<span className="font-medium text-emerald-600">{p.masteredCount}</span></span>
|
||||
{t("chapterChart.errorCount")}:
|
||||
<span className="font-medium text-foreground">{payload.errorCount}</span>
|
||||
<span className="ml-2">
|
||||
{t("chapterChart.masteredLabel")}:
|
||||
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
掌握率:<span className="font-medium text-foreground">{p.masteryRate}%</span>
|
||||
<span className="ml-2">知识点数:<span className="font-medium text-foreground">{p.knowledgePointCount}</span></span>
|
||||
{t("chapterChart.masteryRateLabel")}:
|
||||
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
|
||||
<span className="ml-2">
|
||||
{t("chapterChart.knowledgePointCount")}:
|
||||
<span className="font-medium text-foreground">{payload.knowledgePointCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
{p.topKps && p.topKps.length > 0 ? (
|
||||
{payload.topKps && payload.topKps.length > 0 ? (
|
||||
<div className="border-t pt-1.5 mt-1.5">
|
||||
<div className="text-xs text-muted-foreground mb-1">薄弱知识点:</div>
|
||||
{p.topKps.map((kp) => (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{t("chapterChart.weakKpsLabel")}
|
||||
</div>
|
||||
{payload.topKps.map((kp) => (
|
||||
<div key={kp.knowledgePointName} className="flex justify-between text-xs">
|
||||
<span>{kp.knowledgePointName}</span>
|
||||
<span className="font-medium text-rose-600">{kp.errorCount}</span>
|
||||
@@ -129,7 +161,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-sm">{chapter.chapterTitle}</span>
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{chapter.knowledgePointCount} 个知识点
|
||||
{t("chapterChart.knowledgePointBadge", { count: chapter.knowledgePointCount })}
|
||||
</Badge>
|
||||
</div>
|
||||
{chapter.topKnowledgePoints.length > 0 ? (
|
||||
@@ -144,7 +176,9 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5 text-xs">
|
||||
<span className="font-bold text-rose-600">{chapter.errorCount}</span>
|
||||
<span className="text-muted-foreground">掌握 {Math.round(chapter.masteryRate * 100)}%</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("chapterChart.masteryRateLabel")} {Math.round(chapter.masteryRate * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -17,6 +18,29 @@ interface ClassErrorBarChartProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ClassBarChartPayload {
|
||||
name: string
|
||||
totalErrorItems: number
|
||||
studentCount: number
|
||||
averageErrorPerStudent: number
|
||||
averageMasteryRate: number
|
||||
dueReviewCount: number
|
||||
}
|
||||
|
||||
function isClassBarChartPayload(v: unknown): v is ClassBarChartPayload {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
|
||||
const obj = v as Record<string, unknown>
|
||||
return (
|
||||
typeof obj.name === "string" &&
|
||||
typeof obj.totalErrorItems === "number" &&
|
||||
typeof obj.studentCount === "number" &&
|
||||
typeof obj.averageErrorPerStudent === "number" &&
|
||||
typeof obj.averageMasteryRate === "number" &&
|
||||
typeof obj.dueReviewCount === "number"
|
||||
)
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
"var(--color-chart-1)",
|
||||
"var(--color-chart-2)",
|
||||
@@ -31,6 +55,8 @@ const CHART_COLORS = [
|
||||
* 颜色按班级区分,tooltip 显示学生数/人均/掌握率
|
||||
*/
|
||||
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
@@ -44,7 +70,7 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
totalErrorItems: {
|
||||
label: "错题总数",
|
||||
label: t("classErrorBar.errorCount"),
|
||||
color: "var(--color-chart-1)",
|
||||
},
|
||||
}
|
||||
@@ -52,10 +78,14 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">各班级错题数对比</CardTitle>
|
||||
<CardTitle className="text-base">{t("classErrorBar.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[280px] w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[280px] w-full"
|
||||
aria-label={t("classErrorBar.title")}
|
||||
>
|
||||
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
@@ -73,31 +103,29 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
|
||||
<ChartTooltipContent
|
||||
className="w-[220px]"
|
||||
formatter={(payload: unknown) => {
|
||||
const p = payload as unknown as {
|
||||
name: string
|
||||
totalErrorItems: number
|
||||
studentCount: number
|
||||
averageErrorPerStudent: number
|
||||
averageMasteryRate: number
|
||||
dueReviewCount: number
|
||||
}
|
||||
if (!isClassBarChartPayload(payload)) return null
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="font-medium">{payload.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
错题总数:<span className="font-medium text-foreground">{p.totalErrorItems}</span>
|
||||
{t("classErrorBar.errorCount")}:
|
||||
<span className="font-medium text-foreground">{payload.totalErrorItems}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
学生数:<span className="font-medium text-foreground">{p.studentCount}</span>
|
||||
{t("classErrorBar.studentCount")}:
|
||||
<span className="font-medium text-foreground">{payload.studentCount}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
人均错题:<span className="font-medium text-foreground">{p.averageErrorPerStudent}</span>
|
||||
{t("classErrorBar.avgPerStudent")}:
|
||||
<span className="font-medium text-foreground">{payload.averageErrorPerStudent}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
平均掌握率:<span className="font-medium text-foreground">{p.averageMasteryRate}%</span>
|
||||
{t("classErrorBar.avgMastery")}:
|
||||
<span className="font-medium text-foreground">{payload.averageMasteryRate}%</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
待复习:<span className="font-medium text-rose-600">{p.dueReviewCount}</span>
|
||||
{t("classErrorBar.dueReview")}:
|
||||
<span className="font-medium text-rose-600">{payload.dueReviewCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
@@ -25,6 +26,7 @@ export function ClassFilter({
|
||||
}: ClassFilterProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
const handleSelect = (classId: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
@@ -42,6 +44,8 @@ export function ClassFilter({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={currentClassId === "all"}
|
||||
onClick={() => handleSelect("all")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
@@ -50,7 +54,7 @@ export function ClassFilter({
|
||||
: "bg-card hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">全部班级</span>
|
||||
<span className="font-medium">{t("classFilter.all")}</span>
|
||||
</button>
|
||||
{classes.map((cls) => {
|
||||
const isActive = currentClassId === cls.classId
|
||||
@@ -58,6 +62,8 @@ export function ClassFilter({
|
||||
<button
|
||||
key={cls.classId}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleSelect(cls.classId)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
@@ -71,7 +77,7 @@ export function ClassFilter({
|
||||
variant={isActive ? "secondary" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{cls.totalErrorItems} 错题
|
||||
{t("classFilter.errorCount", { count: cls.totalErrorItems })}
|
||||
</Badge>
|
||||
{cls.dueReviewCount > 0 ? (
|
||||
<span
|
||||
@@ -80,7 +86,7 @@ export function ClassFilter({
|
||||
isActive ? "text-primary-foreground/80" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
{cls.dueReviewCount} 待复习
|
||||
{t("classFilter.dueReview", { count: cls.dueReviewCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -37,64 +36,31 @@ import {
|
||||
type ErrorBookItem,
|
||||
} from "../types"
|
||||
import { ReviewButtons } from "./review-buttons"
|
||||
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
|
||||
import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"
|
||||
|
||||
interface ErrorBookDetailDialogProps {
|
||||
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
|
||||
trigger: React.ReactNode
|
||||
/** 当前学生 ID(用于 AI 薄弱点分析) */
|
||||
/** 当前学生 ID(保留接口兼容,AI 分析已通过 aiAnalysisSlot 注入) */
|
||||
studentId?: string
|
||||
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
|
||||
/** 全部错题列表(保留接口兼容,AI 分析已通过 aiAnalysisSlot 注入) */
|
||||
errorItems?: ErrorBookItem[]
|
||||
/** AI 分析区域插槽(由父组件注入,未提供则不渲染) */
|
||||
aiAnalysisSlot?: React.ReactNode
|
||||
/** 发起变式练习回调(未提供则不渲染变式练习入口) */
|
||||
onStartVariantPractice?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取纯文本(用于 AI 相似题推荐)
|
||||
*
|
||||
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
|
||||
*/
|
||||
function extractQuestionText(content: unknown): string {
|
||||
if (!content) return ""
|
||||
if (typeof content === "string") return content
|
||||
if (typeof content === "object" && content !== null && "text" in content) {
|
||||
const textValue = (content as Record<string, unknown>).text
|
||||
if (typeof textValue === "string") return textValue
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
|
||||
*/
|
||||
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
|
||||
questionText: string
|
||||
questionType: string
|
||||
knowledgePointIds?: string[]
|
||||
errorCount: number
|
||||
masteryLevel: number
|
||||
}> {
|
||||
return items.map((it) => ({
|
||||
questionText: extractQuestionText(it.question?.content),
|
||||
questionType: it.question?.type ?? "unknown",
|
||||
knowledgePointIds: it.knowledgePointIds ?? undefined,
|
||||
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
|
||||
masteryLevel: it.masteryLevel,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }: ErrorBookDetailDialogProps) {
|
||||
export function ErrorBookDetailDialog({
|
||||
item,
|
||||
trigger,
|
||||
aiAnalysisSlot,
|
||||
onStartVariantPractice,
|
||||
}: ErrorBookDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [note, setNote] = useState(item.note ?? "")
|
||||
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
|
||||
const router = useRouter()
|
||||
const t = useTranslations("error-book")
|
||||
const tPractice = useTranslations("practice")
|
||||
|
||||
function handleSaveNote() {
|
||||
startTransition(async () => {
|
||||
@@ -140,49 +106,12 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起错题变式练习。
|
||||
*
|
||||
* 从当前错题出发,创建一个 error_variant 类型的练习会话,
|
||||
* 使用该错题关联的原题进行针对性练习。
|
||||
*/
|
||||
function handleStartVariantPractice() {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
"json",
|
||||
JSON.stringify({
|
||||
practiceType: "error_variant",
|
||||
subjectId: item.subjectId ?? undefined,
|
||||
sourceMeta: {
|
||||
errorBookItemIds: [item.id],
|
||||
sourceQuestionIds: [item.questionId],
|
||||
},
|
||||
questionCount: 10,
|
||||
}),
|
||||
)
|
||||
const res = await createPracticeSessionAction(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
toast.success(res.message ?? tPractice("starter.title"))
|
||||
setOpen(false)
|
||||
router.push(`/student/practice/${res.data.sessionId}`)
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.saveFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
setErrorTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
)
|
||||
}
|
||||
|
||||
// AI 分析所需数据
|
||||
const currentQuestionText = extractQuestionText(item.question?.content)
|
||||
const currentQuestionType = item.question?.type
|
||||
const aiErrorItems = errorItems ? mapErrorItemsForAnalysis(errorItems) : []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
@@ -208,10 +137,10 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
<DialogDescription className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
添加于 {formatDate(item.createdAt)}
|
||||
{t("itemCard.addedAt", { date: formatDate(item.createdAt) })}
|
||||
</span>
|
||||
<span>掌握度: {item.masteryLevel}/5</span>
|
||||
<span>复习 {item.reviewCount} 次</span>
|
||||
<span>{t("itemCard.masteryOutOf", { level: item.masteryLevel })}</span>
|
||||
<span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -219,7 +148,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
<div className="space-y-4 pb-4">
|
||||
{/* 题目内容 */}
|
||||
<section>
|
||||
<h4 className="mb-2 text-sm font-medium">题目</h4>
|
||||
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.question")}</h4>
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{item.question ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
@@ -228,7 +157,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
: JSON.stringify(item.question.content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="text-muted-foreground">题目已删除</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("detailDialog.questionDeleted")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -239,7 +170,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
{item.studentAnswer !== null && item.studentAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
|
||||
我的答案
|
||||
{t("detailDialog.myAnswer")}
|
||||
</h4>
|
||||
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
@@ -253,7 +184,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
{item.correctAnswer !== null && item.correctAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
正确答案
|
||||
{t("detailDialog.correctAnswer")}
|
||||
</h4>
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
@@ -267,40 +198,36 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* AI 分析区(相似题推荐 + 薄弱点分析) */}
|
||||
{studentId && currentQuestionText ? (
|
||||
{/* AI 分析区(由父组件注入) */}
|
||||
{aiAnalysisSlot ? (
|
||||
<section>
|
||||
<h4 className="mb-2 text-sm font-medium">AI 智能分析</h4>
|
||||
<AiErrorBookAnalysis
|
||||
studentId={studentId}
|
||||
subjectId={item.subjectId ?? undefined}
|
||||
currentQuestionText={currentQuestionText}
|
||||
currentQuestionType={currentQuestionType}
|
||||
errorItems={aiErrorItems}
|
||||
/>
|
||||
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.aiAnalysis")}</h4>
|
||||
{aiAnalysisSlot}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 变式练习入口 */}
|
||||
<section>
|
||||
<Button
|
||||
onClick={handleStartVariantPractice}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
variant="default"
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
{isPending ? tPractice("starter.creating") : tPractice("types.error_variant")}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
{tPractice("starter.description")}
|
||||
</p>
|
||||
</section>
|
||||
{/* 变式练习入口(由父组件注入回调) */}
|
||||
{onStartVariantPractice ? (
|
||||
<section>
|
||||
<Button
|
||||
onClick={onStartVariantPractice}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
variant="default"
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
{t("detailDialog.variantPractice")}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
{t("detailDialog.variantPracticeDesc")}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 复习区 */}
|
||||
{item.status !== "mastered" && item.status !== "archived" ? (
|
||||
<section>
|
||||
<h4 className="mb-2 text-sm font-medium">复习自评</h4>
|
||||
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.reviewSelf")}</h4>
|
||||
<ReviewButtons
|
||||
itemId={item.id}
|
||||
onReviewed={() => setOpen(false)}
|
||||
@@ -312,17 +239,19 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
<section>
|
||||
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
学习笔记
|
||||
{t("detailDialog.studyNote")}
|
||||
</h4>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="记录你的反思、解题思路、易错点..."
|
||||
placeholder={t("detailDialog.notePlaceholder")}
|
||||
className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs text-muted-foreground">错误原因标签</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
{t("detailDialog.errorTagsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COMMON_ERROR_TAGS.map((tag) => (
|
||||
<Badge
|
||||
@@ -343,7 +272,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
disabled={isPending}
|
||||
onClick={handleSaveNote}
|
||||
>
|
||||
保存笔记
|
||||
{t("actions.saveNote")}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -352,7 +281,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
<section>
|
||||
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
|
||||
<History className="h-4 w-4" />
|
||||
复习历史
|
||||
{t("detailDialog.reviewHistory")}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{item.reviews.slice(0, 10).map((r) => (
|
||||
@@ -386,7 +315,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
onClick={handleArchive}
|
||||
>
|
||||
<Archive className="h-4 w-4" data-icon="inline-start" />
|
||||
归档
|
||||
{t("actions.archive")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -396,7 +325,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" data-icon="inline-start" />
|
||||
删除
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function ErrorBookFilters() {
|
||||
const t = useTranslations("error-book")
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
|
||||
@@ -37,40 +39,40 @@ export function ErrorBookFilters() {
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="搜索笔记内容..."
|
||||
placeholder={t("filters.searchPlaceholder")}
|
||||
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="状态" />
|
||||
<SelectValue placeholder={t("filters.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="new">待学习</SelectItem>
|
||||
<SelectItem value="learning">学习中</SelectItem>
|
||||
<SelectItem value="mastered">已掌握</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allStatus")}</SelectItem>
|
||||
<SelectItem value="new">{t("status.new")}</SelectItem>
|
||||
<SelectItem value="learning">{t("status.learning")}</SelectItem>
|
||||
<SelectItem value="mastered">{t("status.mastered")}</SelectItem>
|
||||
<SelectItem value="archived">{t("status.archived")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sourceType} onValueChange={(val) => setSourceType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="来源" />
|
||||
<SelectValue placeholder={t("filters.source")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
<SelectItem value="exam">考试</SelectItem>
|
||||
<SelectItem value="homework">作业</SelectItem>
|
||||
<SelectItem value="manual">手动添加</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allSource")}</SelectItem>
|
||||
<SelectItem value="exam">{t("source.exam")}</SelectItem>
|
||||
<SelectItem value="homework">{t("source.homework")}</SelectItem>
|
||||
<SelectItem value="manual">{t("source.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={dueOnly} onValueChange={(val) => setDueOnly(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="复习" />
|
||||
<SelectValue placeholder={t("filters.review")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部错题</SelectItem>
|
||||
<SelectItem value="due">仅看待复习</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allErrors")}</SelectItem>
|
||||
<SelectItem value="due">{t("filters.dueOnly")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Calendar, FileText, BookMarked } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
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 { extractQuestionPreview } from "@/shared/lib/question-content"
|
||||
|
||||
import {
|
||||
ERROR_BOOK_SOURCE_LABEL,
|
||||
@@ -19,45 +23,19 @@ interface ErrorBookItemCardProps {
|
||||
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 t = useTranslations("error-book")
|
||||
const preview = item.question
|
||||
? extractQuestionPreview(item.question.content, t("itemCard.questionContent"))
|
||||
: t("itemCard.questionDeleted")
|
||||
const isDue = item.nextReviewAt ? item.nextReviewAt <= new Date() : false
|
||||
const isMastered = item.status === "mastered"
|
||||
|
||||
const masteryLabel =
|
||||
item.masteryLevel >= 0 && item.masteryLevel <= 5
|
||||
? t(`masteryLevel.${item.masteryLevel}`)
|
||||
: String(item.masteryLevel)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
@@ -87,7 +65,7 @@ export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
|
||||
) : null}
|
||||
{item.question?.difficulty ? (
|
||||
<Badge variant="secondary">
|
||||
难度 {item.question.difficulty}
|
||||
{t("itemCard.difficulty", { level: item.question.difficulty })}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -120,11 +98,13 @@ export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
|
||||
|
||||
<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>
|
||||
<span>{t("itemCard.mastery", { level: masteryLabel })}</span>
|
||||
<span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
|
||||
{item.nextReviewAt && !isMastered ? (
|
||||
<span className={cn(isDue && "font-medium text-rose-600 dark:text-rose-400")}>
|
||||
{isDue ? "需复习" : `下次 ${formatDate(item.nextReviewAt)}`}
|
||||
{isDue
|
||||
? t("itemCard.needReview")
|
||||
: t("itemCard.nextReview", { date: formatDate(item.nextReviewAt) })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { BookX } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -8,19 +12,25 @@ import type { ErrorBookItem } from "../types"
|
||||
|
||||
interface ErrorBookListProps {
|
||||
items: ErrorBookItem[]
|
||||
/** 当前学生 ID(用于 AI 薄弱点分析) */
|
||||
studentId?: string
|
||||
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
|
||||
errorItems?: ErrorBookItem[]
|
||||
/** AI 分析区域插槽(由父组件注入,传递给每个 ErrorBookDetailDialog) */
|
||||
aiAnalysisSlot?: (item: ErrorBookItem) => React.ReactNode
|
||||
/** 发起变式练习回调(由父组件注入) */
|
||||
onStartVariantPractice?: (item: ErrorBookItem) => void
|
||||
}
|
||||
|
||||
export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListProps) {
|
||||
export function ErrorBookList({
|
||||
items,
|
||||
aiAnalysisSlot,
|
||||
onStartVariantPractice,
|
||||
}: ErrorBookListProps): JSX.Element {
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={BookX}
|
||||
title="错题本为空"
|
||||
description="完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)
|
||||
@@ -32,11 +42,11 @@ export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListPro
|
||||
<ErrorBookItemCard key={item.id} item={item}>
|
||||
<ErrorBookDetailDialog
|
||||
item={item}
|
||||
studentId={studentId}
|
||||
errorItems={errorItems}
|
||||
aiAnalysisSlot={aiAnalysisSlot?.(item)}
|
||||
onStartVariantPractice={onStartVariantPractice ? () => onStartVariantPractice(item) : undefined}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
查看详情
|
||||
{t("actions.viewDetail")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import type { ErrorBookStats } from "../types"
|
||||
@@ -9,6 +12,7 @@ interface ErrorBookStatsCardsProps {
|
||||
}
|
||||
|
||||
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
|
||||
const t = useTranslations("error-book")
|
||||
const masteredPercent = stats.totalCount > 0
|
||||
? Math.round(stats.masteredRate * 100)
|
||||
: 0
|
||||
@@ -16,42 +20,42 @@ export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsPro
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatCard
|
||||
title="错题总数"
|
||||
title={t("stats.total")}
|
||||
value={stats.totalCount}
|
||||
icon={BookX}
|
||||
description="累计收录的错题"
|
||||
description={t("stats.totalDesc")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="待学习"
|
||||
title={t("stats.new")}
|
||||
value={stats.newCount}
|
||||
icon={Sparkles}
|
||||
color="text-blue-500"
|
||||
description="尚未开始复习"
|
||||
description={t("stats.newDesc")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="学习中"
|
||||
title={t("stats.learning")}
|
||||
value={stats.learningCount}
|
||||
icon={Repeat}
|
||||
color="text-amber-500"
|
||||
description="正在复习掌握"
|
||||
description={t("stats.learningDesc")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="已掌握"
|
||||
title={t("stats.mastered")}
|
||||
value={stats.masteredCount}
|
||||
icon={GraduationCap}
|
||||
color="text-emerald-500"
|
||||
description={`掌握率 ${masteredPercent}%`}
|
||||
description={t("stats.masteredDesc", { rate: masteredPercent })}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="待复习"
|
||||
title={t("stats.dueReview")}
|
||||
value={stats.dueReviewCount}
|
||||
icon={Clock}
|
||||
color="text-rose-500"
|
||||
description="今日到期复习"
|
||||
description={t("stats.dueReviewDesc")}
|
||||
highlight={stats.dueReviewCount > 0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronDown, ChevronRight, Users } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
@@ -32,6 +34,7 @@ export function GroupedStudentErrorTable({
|
||||
studentNames,
|
||||
basePath,
|
||||
}: GroupedStudentErrorTableProps) {
|
||||
const t = useTranslations("error-book")
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set())
|
||||
|
||||
// 按班级分组
|
||||
@@ -44,7 +47,7 @@ export function GroupedStudentErrorTable({
|
||||
if (!group) {
|
||||
group = {
|
||||
classId: key,
|
||||
className: student.className ?? "未分班",
|
||||
className: student.className ?? t("groupedTable.unclassified"),
|
||||
students: [],
|
||||
totalErrors: 0,
|
||||
averageMasteryRate: 0,
|
||||
@@ -95,6 +98,7 @@ export function GroupedStudentErrorTable({
|
||||
{/* 班级头部(可点击展开) */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => toggleClass(group.classId)}
|
||||
className="flex w-full items-center justify-between gap-3 bg-muted/50 p-3 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
@@ -107,21 +111,21 @@ export function GroupedStudentErrorTable({
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{group.className}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{group.students.length} 人
|
||||
{t("groupedTable.studentCount", { count: group.students.length })}
|
||||
</Badge>
|
||||
{studentsWithErrors.length < group.students.length ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{studentsWithErrors.length} 人有错题
|
||||
{t("groupedTable.studentsWithErrors", { count: studentsWithErrors.length })}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">错题总数</div>
|
||||
<div className="text-xs text-muted-foreground">{t("groupedTable.totalErrors")}</div>
|
||||
<div className="font-bold text-rose-600">{group.totalErrors}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">平均掌握率</div>
|
||||
<div className="text-xs text-muted-foreground">{t("groupedTable.avgMastery")}</div>
|
||||
<div className="font-medium">
|
||||
{Math.round(group.averageMasteryRate * 100)}%
|
||||
</div>
|
||||
@@ -135,18 +139,18 @@ export function GroupedStudentErrorTable({
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">学生</th>
|
||||
<th className="px-3 py-2 text-right font-medium">错题总数</th>
|
||||
<th className="px-3 py-2 text-right font-medium">待学习</th>
|
||||
<th className="px-3 py-2 text-right font-medium">学习中</th>
|
||||
<th className="px-3 py-2 text-right font-medium">已掌握</th>
|
||||
<th className="px-3 py-2 text-right font-medium">待复习</th>
|
||||
<th className="px-3 py-2 font-medium">掌握率</th>
|
||||
<th className="px-3 py-2 font-medium">{t("groupedTable.student")}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.totalErrors")}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.new")}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.learning")}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.mastered")}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.dueReview")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("groupedTable.masteryRate")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.students.map((student) => {
|
||||
const name = studentNames.get(student.studentId) ?? "未知"
|
||||
const name = studentNames.get(student.studentId) ?? t("groupedTable.unknown")
|
||||
const hasErrors = student.totalCount > 0
|
||||
return (
|
||||
<tr
|
||||
@@ -158,12 +162,12 @@ export function GroupedStudentErrorTable({
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{hasErrors ? (
|
||||
<a
|
||||
<Link
|
||||
href={`${basePath}?studentId=${student.studentId}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{name}</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -18,6 +19,27 @@ interface KnowledgePointWeaknessChartProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface KpChartPayload {
|
||||
name: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
chapterTitle: string
|
||||
}
|
||||
|
||||
function isKpChartPayload(v: unknown): v is KpChartPayload {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
|
||||
const obj = v as Record<string, unknown>
|
||||
return (
|
||||
typeof obj.name === "string" &&
|
||||
typeof obj.errorCount === "number" &&
|
||||
typeof obj.masteredCount === "number" &&
|
||||
typeof obj.masteryRate === "number" &&
|
||||
typeof obj.chapterTitle === "string"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识点薄弱度图表
|
||||
* 横向柱状图,按错题数降序
|
||||
@@ -28,6 +50,8 @@ export function KnowledgePointWeaknessChart({
|
||||
data,
|
||||
className,
|
||||
}: KnowledgePointWeaknessChartProps) {
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
@@ -35,12 +59,12 @@ export function KnowledgePointWeaknessChart({
|
||||
errorCount: d.errorCount,
|
||||
masteredCount: d.masteredCount,
|
||||
masteryRate: Number((d.masteryRate * 100).toFixed(0)),
|
||||
chapterTitle: d.chapterTitle ?? "未分类",
|
||||
chapterTitle: d.chapterTitle ?? t("weaknessChart.unclassified"),
|
||||
}))
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
errorCount: {
|
||||
label: "错题数",
|
||||
label: t("weaknessChart.errorCount"),
|
||||
color: "var(--color-chart-1)",
|
||||
},
|
||||
}
|
||||
@@ -48,10 +72,16 @@ export function KnowledgePointWeaknessChart({
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">薄弱知识点 Top {data.length}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t("weaknessChart.title", { count: data.length })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ChartContainer config={chartConfig} className="h-[320px] w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[320px] w-full"
|
||||
aria-label={t("weaknessChart.title", { count: data.length })}
|
||||
>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
@@ -74,25 +104,24 @@ export function KnowledgePointWeaknessChart({
|
||||
<ChartTooltipContent
|
||||
className="w-[240px]"
|
||||
formatter={(payload: unknown) => {
|
||||
const p = payload as unknown as {
|
||||
name: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
chapterTitle: string
|
||||
}
|
||||
if (!isKpChartPayload(payload)) return null
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="font-medium">{payload.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
所属章节:{p.chapterTitle}
|
||||
{t("weaknessChart.chapterLabel", { title: payload.chapterTitle })}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
错题数:<span className="font-medium text-foreground">{p.errorCount}</span>
|
||||
<span className="ml-2">已掌握:<span className="font-medium text-emerald-600">{p.masteredCount}</span></span>
|
||||
{t("weaknessChart.errorCount")}:
|
||||
<span className="font-medium text-foreground">{payload.errorCount}</span>
|
||||
<span className="ml-2">
|
||||
{t("weaknessChart.masteredLabel")}:
|
||||
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
掌握率:<span className="font-medium text-foreground">{p.masteryRate}%</span>
|
||||
{t("weaknessChart.masteryRateLabel")}:
|
||||
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { reviewErrorBookItemAction } from "../actions"
|
||||
@@ -13,47 +14,50 @@ interface ReviewButtonsProps {
|
||||
onReviewed?: () => void
|
||||
}
|
||||
|
||||
const REVIEW_OPTIONS: Array<{
|
||||
type ReviewOption = {
|
||||
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 t = useTranslations("error-book")
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
|
||||
|
||||
const reviewOptions: ReviewOption[] = [
|
||||
{
|
||||
result: "again",
|
||||
label: t("review.again"),
|
||||
description: t("review.againDesc"),
|
||||
icon: RotateCcw,
|
||||
variant: "destructive",
|
||||
},
|
||||
{
|
||||
result: "hard",
|
||||
label: t("review.hard"),
|
||||
description: t("review.hardDesc"),
|
||||
icon: Zap,
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
result: "good",
|
||||
label: t("review.good"),
|
||||
description: t("review.goodDesc"),
|
||||
icon: ThumbsUp,
|
||||
variant: "default",
|
||||
},
|
||||
{
|
||||
result: "easy",
|
||||
label: t("review.easy"),
|
||||
description: t("review.easyDesc"),
|
||||
icon: Check,
|
||||
variant: "outline",
|
||||
},
|
||||
]
|
||||
|
||||
function handleReview(result: ErrorBookReviewResultValue) {
|
||||
setSelected(result)
|
||||
startTransition(async () => {
|
||||
@@ -61,10 +65,10 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
||||
formData.append("json", JSON.stringify({ itemId, result }))
|
||||
const res = await reviewErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "复习结果已记录")
|
||||
toast.success(res.message ?? t("messages.reviewRecorded"))
|
||||
onReviewed?.()
|
||||
} else {
|
||||
toast.error(res.message ?? "记录失败")
|
||||
toast.error(res.message ?? t("messages.recordFailed"))
|
||||
setSelected(null)
|
||||
}
|
||||
})
|
||||
@@ -72,7 +76,7 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{REVIEW_OPTIONS.map((opt) => {
|
||||
{reviewOptions.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
const isLoading = isPending && selected === opt.result
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -17,6 +18,25 @@ interface SubjectDistributionChartProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SubjectDistChartPayload {
|
||||
name: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
}
|
||||
|
||||
function isSubjectDistChartPayload(v: unknown): v is SubjectDistChartPayload {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
|
||||
const obj = v as Record<string, unknown>
|
||||
return (
|
||||
typeof obj.name === "string" &&
|
||||
typeof obj.errorCount === "number" &&
|
||||
typeof obj.masteredCount === "number" &&
|
||||
typeof obj.masteryRate === "number"
|
||||
)
|
||||
}
|
||||
|
||||
const SUBJECT_COLORS = [
|
||||
"var(--color-chart-1)",
|
||||
"var(--color-chart-2)",
|
||||
@@ -34,6 +54,8 @@ export function SubjectDistributionChart({
|
||||
data,
|
||||
className,
|
||||
}: SubjectDistributionChartProps) {
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
@@ -45,7 +67,7 @@ export function SubjectDistributionChart({
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
errorCount: {
|
||||
label: "错题数",
|
||||
label: t("subjectDistChart.errorCount"),
|
||||
color: "var(--color-chart-1)",
|
||||
},
|
||||
}
|
||||
@@ -53,10 +75,14 @@ export function SubjectDistributionChart({
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">各学科错题分布</CardTitle>
|
||||
<CardTitle className="text-base">{t("subjectDistChart.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[280px] w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[280px] w-full"
|
||||
aria-label={t("subjectDistChart.title")}
|
||||
>
|
||||
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
@@ -74,23 +100,21 @@ export function SubjectDistributionChart({
|
||||
<ChartTooltipContent
|
||||
className="w-[200px]"
|
||||
formatter={(payload: unknown) => {
|
||||
const p = payload as unknown as {
|
||||
name: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
masteryRate: number
|
||||
}
|
||||
if (!isSubjectDistChartPayload(payload)) return null
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="font-medium">{payload.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
错题数:<span className="font-medium text-foreground">{p.errorCount}</span>
|
||||
{t("subjectDistChart.errorCount")}:
|
||||
<span className="font-medium text-foreground">{payload.errorCount}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
已掌握:<span className="font-medium text-emerald-600">{p.masteredCount}</span>
|
||||
{t("subjectDistChart.masteredLabel")}:
|
||||
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
掌握率:<span className="font-medium text-foreground">{p.masteryRate}%</span>
|
||||
{t("subjectDistChart.masteryRateLabel")}:
|
||||
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
@@ -25,6 +26,7 @@ export function SubjectTabs({
|
||||
}: SubjectTabsProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
const handleSelect = (subjectId: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
@@ -44,6 +46,8 @@ export function SubjectTabs({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={currentSubjectId === null}
|
||||
onClick={() => handleSelect(null)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
@@ -52,7 +56,7 @@ export function SubjectTabs({
|
||||
: "bg-card hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">全部学科</span>
|
||||
<span className="font-medium">{t("subjectTabs.all")}</span>
|
||||
<Badge
|
||||
variant={currentSubjectId === null ? "secondary" : "outline"}
|
||||
className="text-xs"
|
||||
@@ -66,6 +70,8 @@ export function SubjectTabs({
|
||||
<button
|
||||
key={subject.subjectId}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleSelect(subject.subjectId)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
@@ -88,7 +94,7 @@ export function SubjectTabs({
|
||||
isActive ? "text-primary-foreground/80" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
待复习 {subject.dueReviewCount}
|
||||
{t("subjectTabs.dueReview", { count: subject.dueReviewCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { Flame } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
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"
|
||||
import { extractQuestionPreview } from "@/shared/lib/question-content"
|
||||
|
||||
interface TopWrongQuestion {
|
||||
questionId: string
|
||||
@@ -16,45 +20,36 @@ 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 KNOWN_QUESTION_TYPES = [
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"judgment",
|
||||
"text",
|
||||
"composite",
|
||||
] as const
|
||||
type KnownQuestionType = (typeof KNOWN_QUESTION_TYPES)[number]
|
||||
|
||||
const QUESTION_TYPE_LABEL: Record<string, string> = {
|
||||
single_choice: "单选",
|
||||
multiple_choice: "多选",
|
||||
judgment: "判断",
|
||||
text: "简答",
|
||||
composite: "复合",
|
||||
function isKnownQuestionType(v: string): v is KnownQuestionType {
|
||||
return (KNOWN_QUESTION_TYPES as readonly string[]).includes(v)
|
||||
}
|
||||
|
||||
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
|
||||
const t = useTranslations("error-book")
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Flame className="h-4 w-4" />
|
||||
高频错题
|
||||
{t("topWrong.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Flame}
|
||||
title="暂无高频错题"
|
||||
description="学生完成作业或考试后,错频统计会显示在这里。"
|
||||
title={t("topWrong.emptyTitle")}
|
||||
description={t("topWrong.emptyDesc")}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -67,7 +62,7 @@ export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Flame className="h-4 w-4" />
|
||||
高频错题 Top 10
|
||||
{t("topWrong.topTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -84,19 +79,21 @@ export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm line-clamp-2">
|
||||
{extractPreview(q.questionContent)}
|
||||
{extractQuestionPreview(q.questionContent, t("itemCard.questionContent"), 120)}
|
||||
</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}
|
||||
{isKnownQuestionType(q.questionType)
|
||||
? t(`questionType.${q.questionType}`)
|
||||
: q.questionType}
|
||||
</Badge>
|
||||
<span>{q.errorCount} 人错</span>
|
||||
<span>{t("topWrong.errorCount", { count: q.errorCount })}</span>
|
||||
<span>·</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{q.masteredCount} 人已掌握
|
||||
{t("topWrong.masteredCount", { count: q.masteredCount })}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>掌握率 {Math.round(masteryRate * 100)}%</span>
|
||||
<span>{t("topWrong.masteryRate", { rate: Math.round(masteryRate * 100) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
552
src/modules/error-book/data-access-analytics.ts
Normal file
552
src/modules/error-book/data-access-analytics.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import "server-only"
|
||||
|
||||
import { count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
errorBookItems,
|
||||
questions,
|
||||
knowledgePoints,
|
||||
chapters,
|
||||
subjects,
|
||||
users,
|
||||
classEnrollments,
|
||||
classes,
|
||||
} from "@/shared/db/schema"
|
||||
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
ChapterWeakness,
|
||||
ClassErrorOverview,
|
||||
KnowledgePointWeakness,
|
||||
StudentErrorBookSummary,
|
||||
SubjectErrorOverview,
|
||||
} from "./types"
|
||||
import { buildStudentErrorWhereClause, toStatus, toStringArray } from "./data-access"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 教师/管理员分析查询(SQL 聚合优化)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
|
||||
export async function getStudentErrorBookSummaries(
|
||||
studentIds: string[],
|
||||
subjectId?: string | null
|
||||
): Promise<StudentErrorBookSummary[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
// 使用 SQL 条件聚合(GROUP BY studentId)
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: errorBookItems.studentId,
|
||||
totalCount: count(),
|
||||
newCount: sql<number>`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`,
|
||||
learningCount: sql<number>`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`,
|
||||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
|
||||
dueReviewCount: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
|
||||
lastActivityAt: sql<Date | null>`max(${errorBookItems.updatedAt})`,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
.groupBy(errorBookItems.studentId)
|
||||
|
||||
// 查询学生所属班级(用于按班级分组展示)
|
||||
const enrollmentRows = await db
|
||||
.select({
|
||||
studentId: classEnrollments.studentId,
|
||||
classId: classes.id,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.studentId, studentIds))
|
||||
|
||||
const studentClassMap = new Map<string, { classId: string; className: string }>()
|
||||
for (const row of enrollmentRows) {
|
||||
// 取第一个班级(学生通常只属于一个班)
|
||||
if (!studentClassMap.has(row.studentId)) {
|
||||
studentClassMap.set(row.studentId, { classId: row.classId, className: row.className })
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const totalCount = Number(row.totalCount)
|
||||
const masteredCount = Number(row.masteredCount)
|
||||
const classInfo = studentClassMap.get(row.studentId)
|
||||
return {
|
||||
studentId: row.studentId,
|
||||
totalCount,
|
||||
newCount: Number(row.newCount),
|
||||
learningCount: Number(row.learningCount),
|
||||
masteredCount,
|
||||
dueReviewCount: Number(row.dueReviewCount),
|
||||
masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
|
||||
lastActivityAt: row.lastActivityAt,
|
||||
classId: classInfo?.classId ?? null,
|
||||
className: classInfo?.className ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
|
||||
export async function getTopWrongQuestionsByStudentIds(
|
||||
studentIds: string[],
|
||||
limit = 10,
|
||||
subjectId?: string | null
|
||||
): Promise<Array<{
|
||||
questionId: string
|
||||
questionContent: unknown
|
||||
questionType: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
}>> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
// 使用子查询聚合,避免 GROUP BY JSON 列的问题
|
||||
const aggregatedSubquery = db
|
||||
.select({
|
||||
questionId: errorBookItems.questionId,
|
||||
errorCount: count(),
|
||||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
.groupBy(errorBookItems.questionId)
|
||||
.as("aggregated")
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
questionId: aggregatedSubquery.questionId,
|
||||
errorCount: aggregatedSubquery.errorCount,
|
||||
masteredCount: aggregatedSubquery.masteredCount,
|
||||
content: questions.content,
|
||||
type: questions.type,
|
||||
})
|
||||
.from(aggregatedSubquery)
|
||||
.innerJoin(questions, eq(questions.id, aggregatedSubquery.questionId))
|
||||
.orderBy(desc(aggregatedSubquery.errorCount))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((row) => ({
|
||||
questionId: row.questionId,
|
||||
questionContent: row.content,
|
||||
questionType: row.type,
|
||||
errorCount: Number(row.errorCount),
|
||||
masteredCount: Number(row.masteredCount),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
|
||||
export async function getKnowledgePointWeakness(
|
||||
studentIds: string[],
|
||||
limit = 10,
|
||||
subjectId?: string | null
|
||||
): Promise<KnowledgePointWeakness[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
// knowledgePointIds 是 JSON 数组字段,无法直接用 SQL GROUP BY,保留 JS 聚合
|
||||
// 仅查询必要字段(不查询全行)
|
||||
const rows = await db
|
||||
.select({
|
||||
status: errorBookItems.status,
|
||||
knowledgePointIds: errorBookItems.knowledgePointIds,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
// 展开知识点并统计(使用类型守卫替代 as 断言)
|
||||
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
|
||||
|
||||
for (const row of rows) {
|
||||
const kps = toStringArray(row.knowledgePointIds) ?? []
|
||||
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,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.where(inArray(knowledgePoints.id, kpIds))
|
||||
const kpInfoMap = new Map(kpRows.map((k) => [k.id, { name: k.name, chapterId: k.chapterId }]))
|
||||
|
||||
// 查询章节标题
|
||||
const chapterIds = Array.from(new Set(
|
||||
kpRows.map((k) => k.chapterId).filter((c): c is string => c !== null)
|
||||
))
|
||||
let chapterTitleMap = new Map<string, string>()
|
||||
if (chapterIds.length > 0) {
|
||||
const chapterRows = await db
|
||||
.select({ id: chapters.id, title: chapters.title })
|
||||
.from(chapters)
|
||||
.where(inArray(chapters.id, chapterIds))
|
||||
chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
|
||||
}
|
||||
|
||||
return Array.from(kpMap.entries())
|
||||
.map(([kpId, stat]) => {
|
||||
const info = kpInfoMap.get(kpId)
|
||||
return {
|
||||
knowledgePointId: kpId,
|
||||
knowledgePointName: info?.name ?? "未知知识点",
|
||||
errorCount: stat.errorCount,
|
||||
masteredCount: stat.masteredCount,
|
||||
totalCount: stat.errorCount,
|
||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
||||
chapterId: info?.chapterId ?? null,
|
||||
chapterTitle: info?.chapterId ? (chapterTitleMap.get(info.chapterId) ?? null) : null,
|
||||
}
|
||||
})
|
||||
.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 []
|
||||
|
||||
// 使用 SQL GROUP BY 聚合
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: errorBookItems.subjectId,
|
||||
errorCount: count(),
|
||||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
.groupBy(errorBookItems.subjectId)
|
||||
|
||||
// 查询学科名称
|
||||
const subjectIds = rows
|
||||
.map((r) => r.subjectId)
|
||||
.filter((s): s is string => s !== 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 rows.map((row) => {
|
||||
const errorCount = Number(row.errorCount)
|
||||
const masteredCount = Number(row.masteredCount)
|
||||
const sid = row.subjectId
|
||||
return {
|
||||
subjectId: sid,
|
||||
subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
|
||||
errorCount,
|
||||
masteredCount,
|
||||
masteryRate: errorCount > 0 ? masteredCount / errorCount : 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询章节薄弱度统计(哪些课在错)。
|
||||
* 通过 errorBookItems.knowledgePointIds → knowledgePoints.chapterId → chapters 关联。
|
||||
* 支持按学科过滤(通过 errorBookItems.subjectId)。
|
||||
*/
|
||||
export async function getChapterWeakness(
|
||||
studentIds: string[],
|
||||
limit = 10,
|
||||
subjectId?: string | null
|
||||
): Promise<ChapterWeakness[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
// knowledgePointIds 是 JSON 数组字段,无法直接用 SQL GROUP BY,保留 JS 聚合
|
||||
// 仅查询必要字段(不查询全行)
|
||||
const rows = await db
|
||||
.select({
|
||||
status: errorBookItems.status,
|
||||
knowledgePointIds: errorBookItems.knowledgePointIds,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
// 展开知识点,建立 kpId → 错题统计(使用类型守卫替代 as 断言)
|
||||
const kpErrorMap = new Map<string, { errorCount: number; masteredCount: number }>()
|
||||
for (const row of rows) {
|
||||
const kps = toStringArray(row.knowledgePointIds) ?? []
|
||||
for (const kpId of kps) {
|
||||
const stat = kpErrorMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
|
||||
stat.errorCount++
|
||||
if (toStatus(row.status) === "mastered") stat.masteredCount++
|
||||
kpErrorMap.set(kpId, stat)
|
||||
}
|
||||
}
|
||||
|
||||
if (kpErrorMap.size === 0) return []
|
||||
|
||||
// 查询知识点 → 章节映射
|
||||
const kpIds = Array.from(kpErrorMap.keys())
|
||||
const kpRows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.where(inArray(knowledgePoints.id, kpIds))
|
||||
|
||||
// 按章节聚合
|
||||
const chapterMap = new Map<string, {
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
knowledgePointCount: number
|
||||
topKps: Array<{ knowledgePointId: string; knowledgePointName: string; errorCount: number }>
|
||||
}>()
|
||||
|
||||
for (const kp of kpRows) {
|
||||
if (!kp.chapterId) continue
|
||||
const kpStat = kpErrorMap.get(kp.id)
|
||||
if (!kpStat) continue
|
||||
|
||||
const chapterStat = chapterMap.get(kp.chapterId) ?? {
|
||||
errorCount: 0,
|
||||
masteredCount: 0,
|
||||
knowledgePointCount: 0,
|
||||
topKps: [],
|
||||
}
|
||||
chapterStat.errorCount += kpStat.errorCount
|
||||
chapterStat.masteredCount += kpStat.masteredCount
|
||||
chapterStat.knowledgePointCount++
|
||||
chapterStat.topKps.push({
|
||||
knowledgePointId: kp.id,
|
||||
knowledgePointName: kp.name,
|
||||
errorCount: kpStat.errorCount,
|
||||
})
|
||||
chapterMap.set(kp.chapterId, chapterStat)
|
||||
}
|
||||
|
||||
if (chapterMap.size === 0) return []
|
||||
|
||||
// 查询章节标题
|
||||
const chapterIds = Array.from(chapterMap.keys())
|
||||
const chapterRows = await db
|
||||
.select({ id: chapters.id, title: chapters.title })
|
||||
.from(chapters)
|
||||
.where(inArray(chapters.id, chapterIds))
|
||||
const chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
|
||||
|
||||
return Array.from(chapterMap.entries())
|
||||
.map(([chapterId, stat]) => ({
|
||||
chapterId,
|
||||
chapterTitle: chapterTitleMap.get(chapterId) ?? "未知章节",
|
||||
errorCount: stat.errorCount,
|
||||
masteredCount: stat.masteredCount,
|
||||
knowledgePointCount: stat.knowledgePointCount,
|
||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
||||
topKnowledgePoints: stat.topKps
|
||||
.sort((a, b) => b.errorCount - a.errorCount)
|
||||
.slice(0, 3),
|
||||
}))
|
||||
.sort((a, b) => b.errorCount - a.errorCount)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询按班级分组的错题概览(教师视图:分班显示)。
|
||||
* 对每个班级统计:学生数、错题总数、人均错题数、平均掌握率、待复习数。
|
||||
* 支持按学科过滤。
|
||||
*/
|
||||
export async function getClassErrorOverviews(
|
||||
classIds: string[],
|
||||
subjectId?: string | null
|
||||
): Promise<ClassErrorOverview[]> {
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
// 查询每个班级的学生
|
||||
const enrollmentRows = await db
|
||||
.select({
|
||||
classId: classEnrollments.classId,
|
||||
studentId: classEnrollments.studentId,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.classId, classIds))
|
||||
|
||||
const classStudentMap = new Map<string, { className: string; studentIds: Set<string> }>()
|
||||
for (const row of enrollmentRows) {
|
||||
const entry = classStudentMap.get(row.classId) ?? { className: row.className, studentIds: new Set<string>() }
|
||||
entry.studentIds.add(row.studentId)
|
||||
classStudentMap.set(row.classId, entry)
|
||||
}
|
||||
|
||||
// 查询所有相关学生的错题(使用 SQL 聚合按 studentId 分组)
|
||||
const allStudentIds = Array.from(new Set(enrollmentRows.map((r) => r.studentId)))
|
||||
if (allStudentIds.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
const whereClause = buildStudentErrorWhereClause(allStudentIds, subjectId)
|
||||
const errorRows = await db
|
||||
.select({
|
||||
studentId: errorBookItems.studentId,
|
||||
total: count(),
|
||||
mastered: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
|
||||
due: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
.groupBy(errorBookItems.studentId)
|
||||
|
||||
// 按学生聚合结果映射
|
||||
const studentStatMap = new Map<string, { total: number; mastered: number; due: number }>()
|
||||
for (const row of errorRows) {
|
||||
studentStatMap.set(row.studentId, {
|
||||
total: Number(row.total),
|
||||
mastered: Number(row.mastered),
|
||||
due: Number(row.due),
|
||||
})
|
||||
}
|
||||
|
||||
// 按班级聚合
|
||||
return Array.from(classStudentMap.entries()).map(([classId, entry]) => {
|
||||
const studentIds = Array.from(entry.studentIds)
|
||||
let totalError = 0
|
||||
let totalMastered = 0
|
||||
let totalDue = 0
|
||||
for (const sid of studentIds) {
|
||||
const stat = studentStatMap.get(sid)
|
||||
if (stat) {
|
||||
totalError += stat.total
|
||||
totalMastered += stat.mastered
|
||||
totalDue += stat.due
|
||||
}
|
||||
}
|
||||
return {
|
||||
classId,
|
||||
className: entry.className,
|
||||
studentCount: studentIds.length,
|
||||
totalErrorItems: totalError,
|
||||
averageErrorPerStudent: studentIds.length > 0 ? totalError / studentIds.length : 0,
|
||||
averageMasteryRate: totalError > 0 ? totalMastered / totalError : 0,
|
||||
dueReviewCount: totalDue,
|
||||
}
|
||||
}).sort((a, b) => b.totalErrorItems - a.totalErrorItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询按学科分组的错题概览(用于学科 Tab 展示)。
|
||||
* 返回每个学科的错题总数、涉及学生数、平均掌握率、待复习数。
|
||||
*/
|
||||
export async function getSubjectErrorOverviews(
|
||||
studentIds: string[]
|
||||
): Promise<SubjectErrorOverview[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
// 使用 SQL GROUP BY 聚合(含 count distinct studentId)
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: errorBookItems.subjectId,
|
||||
totalErrorItems: count(),
|
||||
studentCount: sql<number>`count(distinct ${errorBookItems.studentId})`,
|
||||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
|
||||
dueReviewCount: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
.groupBy(errorBookItems.subjectId)
|
||||
|
||||
// 过滤无学科的错题
|
||||
const filtered = rows.filter((r) => r.subjectId !== null)
|
||||
if (filtered.length === 0) return []
|
||||
|
||||
// 查询学科名称
|
||||
const subjectIds = filtered.map((r) => r.subjectId as string)
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.id, subjectIds))
|
||||
const subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
|
||||
|
||||
return filtered
|
||||
.map((row) => {
|
||||
const totalErrorItems = Number(row.totalErrorItems)
|
||||
const masteredCount = Number(row.masteredCount)
|
||||
const sid = row.subjectId as string
|
||||
return {
|
||||
subjectId: sid,
|
||||
subjectName: subjectNameMap.get(sid) ?? "未知学科",
|
||||
totalErrorItems,
|
||||
studentCount: Number(row.studentCount),
|
||||
averageMasteryRate: totalErrorItems > 0 ? masteredCount / totalErrorItems : 0,
|
||||
dueReviewCount: Number(row.dueReviewCount),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.totalErrorItems - a.totalErrorItems)
|
||||
}
|
||||
|
||||
/** 查询学生姓名映射 */
|
||||
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 ?? "未知"]))
|
||||
}
|
||||
|
||||
/** 按班级 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, ROLE_NAMES.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)
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, not, or, sql, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
errorBookItems,
|
||||
errorBookReviews,
|
||||
questions,
|
||||
questionsToKnowledgePoints,
|
||||
knowledgePoints,
|
||||
chapters,
|
||||
subjects,
|
||||
users,
|
||||
classEnrollments,
|
||||
classes,
|
||||
} from "@/shared/db/schema"
|
||||
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import {
|
||||
calculateNewInterval,
|
||||
calculateNewMastery,
|
||||
@@ -34,18 +26,9 @@ import type {
|
||||
ErrorBookStats,
|
||||
ErrorBookStatusValue,
|
||||
GetErrorBookItemsParams,
|
||||
KnowledgePointWeakness,
|
||||
ChapterWeakness,
|
||||
ClassErrorOverview,
|
||||
SubjectErrorOverview,
|
||||
StudentErrorBookSummary,
|
||||
} from "./types"
|
||||
import type { ErrorBookReviewResult } from "./schema"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SM-2 间隔重复算法(简化版)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型守卫
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -59,27 +42,41 @@ const toReviewResult = (v: string | null | undefined): ErrorBookReviewResult =>
|
||||
const isStatus = (v: unknown): v is ErrorBookStatusValue =>
|
||||
v === "new" || v === "learning" || v === "mastered" || v === "archived"
|
||||
|
||||
const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
|
||||
/** 将数据库状态字符串转换为 ErrorBookStatusValue,无效值回退为 "new" */
|
||||
export const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
|
||||
isStatus(v) ? v : "new"
|
||||
|
||||
/** 类型守卫:判断未知值是否为 string[] */
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
/** 将未知值安全转换为 string[] | null(用于 JSON 列字段) */
|
||||
export function toStringArray(v: unknown): string[] | null {
|
||||
return isStringArray(v) ? v : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 行映射
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
|
||||
question?: typeof questions.$inferSelect | null
|
||||
subject?: typeof subjects.$inferSelect | null
|
||||
}): ErrorBookItem {
|
||||
/** 错题条目查询结果类型(含关联的 question/subject 字段) */
|
||||
type ErrorBookItemWithRelations = typeof errorBookItems.$inferSelect & {
|
||||
question?: { id: string; content: unknown; type: string; difficulty: number | null } | null
|
||||
subject?: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
function mapRowToItem(row: ErrorBookItemWithRelations): ErrorBookItem {
|
||||
return {
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
questionId: row.questionId,
|
||||
sourceType: row.sourceType as ErrorBookItem["sourceType"],
|
||||
sourceType: row.sourceType,
|
||||
sourceId: row.sourceId,
|
||||
studentAnswer: row.studentAnswer,
|
||||
correctAnswer: row.correctAnswer,
|
||||
subjectId: row.subjectId,
|
||||
knowledgePointIds: row.knowledgePointIds as string[] | null,
|
||||
knowledgePointIds: toStringArray(row.knowledgePointIds),
|
||||
status: toStatus(row.status),
|
||||
masteryLevel: row.masteryLevel,
|
||||
nextReviewAt: row.nextReviewAt,
|
||||
@@ -87,7 +84,7 @@ function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
|
||||
reviewCount: row.reviewCount,
|
||||
correctStreak: row.correctStreak,
|
||||
note: row.note,
|
||||
errorTags: row.errorTags as string[] | null,
|
||||
errorTags: toStringArray(row.errorTags),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
question: row.question
|
||||
@@ -126,12 +123,13 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
|
||||
|
||||
if (dueOnly) {
|
||||
const now = new Date()
|
||||
conditions.push(
|
||||
or(
|
||||
isNull(errorBookItems.nextReviewAt),
|
||||
lte(errorBookItems.nextReviewAt, now)
|
||||
)!
|
||||
const dueCondition = or(
|
||||
isNull(errorBookItems.nextReviewAt),
|
||||
lte(errorBookItems.nextReviewAt, now)
|
||||
)
|
||||
if (dueCondition) {
|
||||
conditions.push(dueCondition)
|
||||
}
|
||||
}
|
||||
|
||||
if (q && q.trim().length > 0) {
|
||||
@@ -172,7 +170,7 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
|
||||
})
|
||||
|
||||
return {
|
||||
data: rows.map((row) => mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])),
|
||||
data: rows.map((row) => mapRowToItem(row)),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -219,7 +217,7 @@ export const getErrorBookItemById = cache(async (
|
||||
|
||||
if (!row) return null
|
||||
|
||||
const base = mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])
|
||||
const base = mapRowToItem(row)
|
||||
const reviews: ErrorBookReviewRecord[] = (row.reviews ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
result: toReviewResult(r.result),
|
||||
@@ -232,49 +230,65 @@ export const getErrorBookItemById = cache(async (
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询:错题本统计
|
||||
// 查询:错题本统计(SQL 聚合优化)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorBookStats> => {
|
||||
const now = new Date()
|
||||
|
||||
const rows = await db
|
||||
// 使用 SQL GROUP BY 聚合状态计数
|
||||
const statusCounts = await db
|
||||
.select({
|
||||
status: errorBookItems.status,
|
||||
nextReviewAt: errorBookItems.nextReviewAt,
|
||||
count: count(),
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(eq(errorBookItems.studentId, studentId))
|
||||
.groupBy(errorBookItems.status)
|
||||
|
||||
const total = rows.length
|
||||
let totalCount = 0
|
||||
let newCount = 0
|
||||
let learningCount = 0
|
||||
let masteredCount = 0
|
||||
let archivedCount = 0
|
||||
let dueReviewCount = 0
|
||||
|
||||
for (const row of rows) {
|
||||
for (const row of statusCounts) {
|
||||
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++
|
||||
}
|
||||
}
|
||||
totalCount += row.count
|
||||
if (status === "new") newCount = row.count
|
||||
else if (status === "learning") learningCount = row.count
|
||||
else if (status === "mastered") masteredCount = row.count
|
||||
else if (status === "archived") archivedCount = row.count
|
||||
}
|
||||
|
||||
// 待复习数:单独查询(status NOT IN mastered/archived AND due)
|
||||
const dueConditions: SQL[] = [
|
||||
eq(errorBookItems.studentId, studentId),
|
||||
not(inArray(errorBookItems.status, ["mastered", "archived"])),
|
||||
]
|
||||
const dueCondition = or(
|
||||
isNull(errorBookItems.nextReviewAt),
|
||||
lte(errorBookItems.nextReviewAt, now)
|
||||
)
|
||||
if (dueCondition) {
|
||||
dueConditions.push(dueCondition)
|
||||
}
|
||||
|
||||
const dueReviewResult = await db
|
||||
.select({ value: count() })
|
||||
.from(errorBookItems)
|
||||
.where(and(...dueConditions))
|
||||
|
||||
const dueReviewCount = Number(dueReviewResult[0]?.value ?? 0)
|
||||
|
||||
return {
|
||||
totalCount: total,
|
||||
totalCount,
|
||||
newCount,
|
||||
learningCount,
|
||||
masteredCount,
|
||||
archivedCount,
|
||||
dueReviewCount,
|
||||
masteredRate: total > 0 ? masteredCount / total : 0,
|
||||
masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -448,11 +462,11 @@ export {
|
||||
} from "./data-access-collection"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 跨模块查询接口:供教师/家长视图使用
|
||||
// 共享工具函数:供 data-access-analytics.ts 使用
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 构建学生错题查询的 where 条件(支持按学科过滤) */
|
||||
function buildStudentErrorWhereClause(
|
||||
export function buildStudentErrorWhereClause(
|
||||
studentIds: string[],
|
||||
subjectId?: string | null
|
||||
): SQL | undefined {
|
||||
@@ -462,568 +476,3 @@ function buildStudentErrorWhereClause(
|
||||
}
|
||||
return and(...conditions)
|
||||
}
|
||||
|
||||
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
|
||||
export async function getStudentErrorBookSummaries(
|
||||
studentIds: string[],
|
||||
subjectId?: string | null
|
||||
): Promise<StudentErrorBookSummary[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: errorBookItems.studentId,
|
||||
status: errorBookItems.status,
|
||||
nextReviewAt: errorBookItems.nextReviewAt,
|
||||
updatedAt: errorBookItems.updatedAt,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
// 查询学生所属班级(用于按班级分组展示)
|
||||
const enrollmentRows = await db
|
||||
.select({
|
||||
studentId: classEnrollments.studentId,
|
||||
classId: classes.id,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.studentId, studentIds))
|
||||
|
||||
const studentClassMap = new Map<string, { classId: string; className: string }>()
|
||||
for (const row of enrollmentRows) {
|
||||
// 取第一个班级(学生通常只属于一个班)
|
||||
if (!studentClassMap.has(row.studentId)) {
|
||||
studentClassMap.set(row.studentId, { classId: row.classId, className: row.className })
|
||||
}
|
||||
}
|
||||
|
||||
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]) => {
|
||||
const classInfo = studentClassMap.get(studentId)
|
||||
return {
|
||||
studentId,
|
||||
...stat,
|
||||
masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
|
||||
classId: classInfo?.classId ?? null,
|
||||
className: classInfo?.className ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
|
||||
export async function getTopWrongQuestionsByStudentIds(
|
||||
studentIds: string[],
|
||||
limit = 10,
|
||||
subjectId?: string | null
|
||||
): Promise<Array<{
|
||||
questionId: string
|
||||
questionContent: unknown
|
||||
questionType: string
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
}>> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
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(whereClause)
|
||||
|
||||
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,
|
||||
subjectId?: string | null
|
||||
): Promise<KnowledgePointWeakness[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
// 查询这些学生的所有错题条目(含知识点)
|
||||
const rows = await db
|
||||
.select({
|
||||
itemId: errorBookItems.id,
|
||||
status: errorBookItems.status,
|
||||
knowledgePointIds: errorBookItems.knowledgePointIds,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
// 展开知识点并统计
|
||||
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,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.where(inArray(knowledgePoints.id, kpIds))
|
||||
const kpInfoMap = new Map(kpRows.map((k) => [k.id, { name: k.name, chapterId: k.chapterId }]))
|
||||
|
||||
// 查询章节标题
|
||||
const chapterIds = Array.from(new Set(
|
||||
kpRows.map((k) => k.chapterId).filter((c): c is string => c !== null)
|
||||
))
|
||||
let chapterTitleMap = new Map<string, string>()
|
||||
if (chapterIds.length > 0) {
|
||||
const chapterRows = await db
|
||||
.select({ id: chapters.id, title: chapters.title })
|
||||
.from(chapters)
|
||||
.where(inArray(chapters.id, chapterIds))
|
||||
chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
|
||||
}
|
||||
|
||||
return Array.from(kpMap.entries())
|
||||
.map(([kpId, stat]) => {
|
||||
const info = kpInfoMap.get(kpId)
|
||||
return {
|
||||
knowledgePointId: kpId,
|
||||
knowledgePointName: info?.name ?? "未知知识点",
|
||||
errorCount: stat.errorCount,
|
||||
masteredCount: stat.masteredCount,
|
||||
totalCount: stat.errorCount,
|
||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
||||
chapterId: info?.chapterId ?? null,
|
||||
chapterTitle: info?.chapterId ? (chapterTitleMap.get(info.chapterId) ?? null) : null,
|
||||
}
|
||||
})
|
||||
.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,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询章节薄弱度统计(哪些课在错)。
|
||||
* 通过 errorBookItems.knowledgePointIds → knowledgePoints.chapterId → chapters 关联。
|
||||
* 支持按学科过滤(通过 errorBookItems.subjectId)。
|
||||
*/
|
||||
export async function getChapterWeakness(
|
||||
studentIds: string[],
|
||||
limit = 10,
|
||||
subjectId?: string | null
|
||||
): Promise<ChapterWeakness[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||
|
||||
// 查询错题条目(含知识点和状态)
|
||||
const rows = await db
|
||||
.select({
|
||||
itemId: errorBookItems.id,
|
||||
status: errorBookItems.status,
|
||||
knowledgePointIds: errorBookItems.knowledgePointIds,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
// 展开知识点,建立 kpId → 错题统计
|
||||
const kpErrorMap = 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 = kpErrorMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
|
||||
stat.errorCount++
|
||||
if (toStatus(row.status) === "mastered") stat.masteredCount++
|
||||
kpErrorMap.set(kpId, stat)
|
||||
}
|
||||
}
|
||||
|
||||
if (kpErrorMap.size === 0) return []
|
||||
|
||||
// 查询知识点 → 章节映射
|
||||
const kpIds = Array.from(kpErrorMap.keys())
|
||||
const kpRows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
chapterId: knowledgePoints.chapterId,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.where(inArray(knowledgePoints.id, kpIds))
|
||||
|
||||
// 按章节聚合
|
||||
const chapterMap = new Map<string, {
|
||||
errorCount: number
|
||||
masteredCount: number
|
||||
knowledgePointCount: number
|
||||
topKps: Array<{ knowledgePointId: string; knowledgePointName: string; errorCount: number }>
|
||||
}>()
|
||||
|
||||
for (const kp of kpRows) {
|
||||
if (!kp.chapterId) continue
|
||||
const kpStat = kpErrorMap.get(kp.id)
|
||||
if (!kpStat) continue
|
||||
|
||||
const chapterStat = chapterMap.get(kp.chapterId) ?? {
|
||||
errorCount: 0,
|
||||
masteredCount: 0,
|
||||
knowledgePointCount: 0,
|
||||
topKps: [],
|
||||
}
|
||||
chapterStat.errorCount += kpStat.errorCount
|
||||
chapterStat.masteredCount += kpStat.masteredCount
|
||||
chapterStat.knowledgePointCount++
|
||||
chapterStat.topKps.push({
|
||||
knowledgePointId: kp.id,
|
||||
knowledgePointName: kp.name,
|
||||
errorCount: kpStat.errorCount,
|
||||
})
|
||||
chapterMap.set(kp.chapterId, chapterStat)
|
||||
}
|
||||
|
||||
if (chapterMap.size === 0) return []
|
||||
|
||||
// 查询章节标题
|
||||
const chapterIds = Array.from(chapterMap.keys())
|
||||
const chapterRows = await db
|
||||
.select({ id: chapters.id, title: chapters.title })
|
||||
.from(chapters)
|
||||
.where(inArray(chapters.id, chapterIds))
|
||||
const chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
|
||||
|
||||
return Array.from(chapterMap.entries())
|
||||
.map(([chapterId, stat]) => ({
|
||||
chapterId,
|
||||
chapterTitle: chapterTitleMap.get(chapterId) ?? "未知章节",
|
||||
errorCount: stat.errorCount,
|
||||
masteredCount: stat.masteredCount,
|
||||
knowledgePointCount: stat.knowledgePointCount,
|
||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
||||
topKnowledgePoints: stat.topKps
|
||||
.sort((a, b) => b.errorCount - a.errorCount)
|
||||
.slice(0, 3),
|
||||
}))
|
||||
.sort((a, b) => b.errorCount - a.errorCount)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询按班级分组的错题概览(教师视图:分班显示)。
|
||||
* 对每个班级统计:学生数、错题总数、人均错题数、平均掌握率、待复习数。
|
||||
* 支持按学科过滤。
|
||||
*/
|
||||
export async function getClassErrorOverviews(
|
||||
classIds: string[],
|
||||
subjectId?: string | null
|
||||
): Promise<ClassErrorOverview[]> {
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
// 查询每个班级的学生
|
||||
const enrollmentRows = await db
|
||||
.select({
|
||||
classId: classEnrollments.classId,
|
||||
studentId: classEnrollments.studentId,
|
||||
className: classes.name,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.classId, classIds))
|
||||
|
||||
const classStudentMap = new Map<string, { className: string; studentIds: Set<string> }>()
|
||||
for (const row of enrollmentRows) {
|
||||
const entry = classStudentMap.get(row.classId) ?? { className: row.className, studentIds: new Set<string>() }
|
||||
entry.studentIds.add(row.studentId)
|
||||
classStudentMap.set(row.classId, entry)
|
||||
}
|
||||
|
||||
// 查询所有相关学生的错题
|
||||
const allStudentIds = Array.from(new Set(enrollmentRows.map((r) => r.studentId)))
|
||||
if (allStudentIds.length === 0) return []
|
||||
|
||||
const whereClause = buildStudentErrorWhereClause(allStudentIds, subjectId)
|
||||
const errorRows = await db
|
||||
.select({
|
||||
studentId: errorBookItems.studentId,
|
||||
status: errorBookItems.status,
|
||||
nextReviewAt: errorBookItems.nextReviewAt,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
|
||||
const now = new Date()
|
||||
// 按学生聚合
|
||||
const studentStatMap = new Map<string, { total: number; mastered: number; due: number }>()
|
||||
for (const row of errorRows) {
|
||||
const stat = studentStatMap.get(row.studentId) ?? { total: 0, mastered: 0, due: 0 }
|
||||
stat.total++
|
||||
const status = toStatus(row.status)
|
||||
if (status === "mastered") stat.mastered++
|
||||
if (status !== "mastered" && status !== "archived") {
|
||||
if (!row.nextReviewAt || row.nextReviewAt <= now) stat.due++
|
||||
}
|
||||
studentStatMap.set(row.studentId, stat)
|
||||
}
|
||||
|
||||
// 按班级聚合
|
||||
return Array.from(classStudentMap.entries()).map(([classId, entry]) => {
|
||||
const studentIds = Array.from(entry.studentIds)
|
||||
let totalError = 0
|
||||
let totalMastered = 0
|
||||
let totalDue = 0
|
||||
for (const sid of studentIds) {
|
||||
const stat = studentStatMap.get(sid)
|
||||
if (stat) {
|
||||
totalError += stat.total
|
||||
totalMastered += stat.mastered
|
||||
totalDue += stat.due
|
||||
}
|
||||
}
|
||||
return {
|
||||
classId,
|
||||
className: entry.className,
|
||||
studentCount: studentIds.length,
|
||||
totalErrorItems: totalError,
|
||||
averageErrorPerStudent: studentIds.length > 0 ? totalError / studentIds.length : 0,
|
||||
averageMasteryRate: totalError > 0 ? totalMastered / totalError : 0,
|
||||
dueReviewCount: totalDue,
|
||||
}
|
||||
}).sort((a, b) => b.totalErrorItems - a.totalErrorItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询按学科分组的错题概览(用于学科 Tab 展示)。
|
||||
* 返回每个学科的错题总数、涉及学生数、平均掌握率、待复习数。
|
||||
*/
|
||||
export async function getSubjectErrorOverviews(
|
||||
studentIds: string[]
|
||||
): Promise<SubjectErrorOverview[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: errorBookItems.subjectId,
|
||||
studentId: errorBookItems.studentId,
|
||||
status: errorBookItems.status,
|
||||
nextReviewAt: errorBookItems.nextReviewAt,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
|
||||
const now = new Date()
|
||||
const subjectMap = new Map<string, {
|
||||
totalErrorItems: number
|
||||
masteredCount: number
|
||||
dueReviewCount: number
|
||||
studentSet: Set<string>
|
||||
}>()
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.subjectId
|
||||
if (!key) continue // 跳过无学科的错题
|
||||
const stat = subjectMap.get(key) ?? {
|
||||
totalErrorItems: 0,
|
||||
masteredCount: 0,
|
||||
dueReviewCount: 0,
|
||||
studentSet: new Set<string>(),
|
||||
}
|
||||
stat.totalErrorItems++
|
||||
stat.studentSet.add(row.studentId)
|
||||
const status = toStatus(row.status)
|
||||
if (status === "mastered") stat.masteredCount++
|
||||
if (status !== "mastered" && status !== "archived") {
|
||||
if (!row.nextReviewAt || row.nextReviewAt <= now) stat.dueReviewCount++
|
||||
}
|
||||
subjectMap.set(key, stat)
|
||||
}
|
||||
|
||||
if (subjectMap.size === 0) return []
|
||||
|
||||
// 查询学科名称
|
||||
const subjectIds = Array.from(subjectMap.keys())
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.id, subjectIds))
|
||||
const subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
|
||||
|
||||
return Array.from(subjectMap.entries())
|
||||
.map(([sid, stat]) => ({
|
||||
subjectId: sid,
|
||||
subjectName: subjectNameMap.get(sid) ?? "未知学科",
|
||||
totalErrorItems: stat.totalErrorItems,
|
||||
studentCount: stat.studentSet.size,
|
||||
averageMasteryRate: stat.totalErrorItems > 0 ? stat.masteredCount / stat.totalErrorItems : 0,
|
||||
dueReviewCount: stat.dueReviewCount,
|
||||
}))
|
||||
.sort((a, b) => b.totalErrorItems - a.totalErrorItems)
|
||||
}
|
||||
|
||||
/** 查询学生姓名映射 */
|
||||
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 ?? "未知"]))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user