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