fix(data-access): 修复 tsc 错误 + 完成 adaptive-practice/elective 迁移至 cacheFn

This commit is contained in:
SpecialX
2026-07-05 22:24:56 +08:00
parent 27374d1b2c
commit 841b130f4c
8 changed files with 320 additions and 136 deletions

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { BarChart3 } from "lucide-react" import { BarChart3 } from "lucide-react"
@@ -16,6 +17,26 @@ import { cn } from "@/shared/lib/utils"
import type { ClassKnowledgePointWeakness } from "@/modules/adaptive-practice/data-access-analytics" import type { ClassKnowledgePointWeakness } from "@/modules/adaptive-practice/data-access-analytics"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 }
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_BASE_PROPS = {
dataKey: "name",
tickLine: false,
axisLine: false,
tickMargin: 8,
}
const Y_AXIS_BASE_PROPS = { tickLine: false, axisLine: false, width: 40 }
const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0]
function truncateTick(value: string): string {
return value.length > 8 ? `${value.slice(0, 8)}...` : value
}
function formatPercentTick(value: number): string {
return `${value}%`
}
interface ClassKnowledgePointWeaknessChartProps { interface ClassKnowledgePointWeaknessChartProps {
data: ClassKnowledgePointWeakness[] data: ClassKnowledgePointWeakness[]
className?: string className?: string
@@ -41,6 +62,36 @@ export function ClassKnowledgePointWeaknessChart({
}: ClassKnowledgePointWeaknessChartProps) { }: ClassKnowledgePointWeaknessChartProps) {
const t = useTranslations("practice") const t = useTranslations("practice")
const renderTooltip = useCallback(
(payload: unknown) => {
// 从 unknown 转换recharts tooltip payload 类型未知,需类型断言
const p = payload as {
name: string
errorRate: number
totalAnswers: number
wrongAnswers: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.totalAnswers")}
<span className="font-medium text-foreground">{p.totalAnswers}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.wrongAnswers")}
<span className="font-medium text-rose-600">{p.wrongAnswers}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.errorRate")}
<span className="font-medium text-rose-600">{p.errorRate}%</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) { if (data.length === 0) {
return ( return (
<Card className={cn("shadow-none", className)}> <Card className={cn("shadow-none", className)}>
@@ -82,56 +133,25 @@ export function ClassKnowledgePointWeaknessChart({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full"> <ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}> <BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} /> <CartesianGrid {...GRID_PROPS} />
<XAxis <XAxis
dataKey="name" {...X_AXIS_BASE_PROPS}
tickLine={false} tickFormatter={truncateTick}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/> />
<YAxis <YAxis
tickLine={false} {...Y_AXIS_BASE_PROPS}
axisLine={false} tickFormatter={formatPercentTick}
width={40}
tickFormatter={(value: number) => `${value}%`}
/> />
<ChartTooltip <ChartTooltip
content={ content={
<ChartTooltipContent <ChartTooltipContent
className="w-[220px]" className="w-[220px]"
formatter={(payload: unknown) => { formatter={renderTooltip}
const p = payload as unknown as {
name: string
errorRate: number
totalAnswers: number
wrongAnswers: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.totalAnswers")}
<span className="font-medium text-foreground">{p.totalAnswers}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.wrongAnswers")}
<span className="font-medium text-rose-600">{p.wrongAnswers}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.errorRate")}
<span className="font-medium text-rose-600">{p.errorRate}%</span>
</div>
</div>
)
}}
/> />
} }
/> />
<Bar dataKey="errorRate" radius={[4, 4, 0, 0]}> <Bar dataKey="errorRate" radius={BAR_RADIUS}>
{chartData.map((_, idx) => ( {chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} /> <Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))} ))}

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -14,6 +15,18 @@ import { cn } from "@/shared/lib/utils"
import type { PracticeTypeBreakdown } from "@/modules/adaptive-practice/data-access-analytics" import type { PracticeTypeBreakdown } from "@/modules/adaptive-practice/data-access-analytics"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 }
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_PROPS = {
dataKey: "name",
tickLine: false,
axisLine: false,
tickMargin: 8,
}
const Y_AXIS_PROPS = { tickLine: false, axisLine: false, width: 36 }
const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0]
interface PracticeTypeBreakdownChartProps { interface PracticeTypeBreakdownChartProps {
data: PracticeTypeBreakdown[] data: PracticeTypeBreakdown[]
className?: string className?: string
@@ -39,46 +52,10 @@ export function PracticeTypeBreakdownChart({
}: PracticeTypeBreakdownChartProps) { }: PracticeTypeBreakdownChartProps) {
const t = useTranslations("practice") const t = useTranslations("practice")
if (data.length === 0) return null const renderTooltip = useCallback(
(payload: unknown) => {
const chartData = data.map((d) => ({ // 从 unknown 转换recharts tooltip payload 类型未知,需类型断言
name: t(`types.${d.practiceType}` as const), const p = payload as {
sessionCount: d.sessionCount,
totalQuestions: d.totalQuestions,
correctCount: d.correctCount,
accuracy: Number((d.accuracy * 100).toFixed(0)),
}))
const chartConfig: ChartConfig = {
sessionCount: {
label: t("teacher.classComparison.totalSessions"),
color: "var(--color-chart-1)",
},
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base">{t("teacher.typeBreakdown.title")}</CardTitle>
<CardDescription>{t("teacher.typeBreakdown.description")}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} width={36} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string name: string
sessionCount: number sessionCount: number
totalQuestions: number totalQuestions: number
@@ -106,11 +83,48 @@ export function PracticeTypeBreakdownChart({
</div> </div>
</div> </div>
) )
}} },
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
name: t(`types.${d.practiceType}` as const),
sessionCount: d.sessionCount,
totalQuestions: d.totalQuestions,
correctCount: d.correctCount,
accuracy: Number((d.accuracy * 100).toFixed(0)),
}))
const chartConfig: ChartConfig = {
sessionCount: {
label: t("teacher.classComparison.totalSessions"),
color: "var(--color-chart-1)",
},
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base">{t("teacher.typeBreakdown.title")}</CardTitle>
<CardDescription>{t("teacher.typeBreakdown.description")}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis {...X_AXIS_PROPS} />
<YAxis {...Y_AXIS_PROPS} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={renderTooltip}
/> />
} }
/> />
<Bar dataKey="sessionCount" radius={[4, 4, 0, 0]}> <Bar dataKey="sessionCount" radius={BAR_RADIUS}>
{chartData.map((_, idx) => ( {chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} /> <Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))} ))}

View File

@@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { and, count, desc, eq, inArray, sql } from "drizzle-orm" import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -64,7 +64,7 @@ export interface PracticeTypeBreakdown {
* *
* @param classId 班级 ID * @param classId 班级 ID
*/ */
export const getClassPracticeStats = cache(async ( export const getClassPracticeStatsRaw = async (
classId: string, classId: string,
): Promise<ClassPracticeStats | null> => { ): Promise<ClassPracticeStats | null> => {
const studentIds = await getActiveStudentIdsByClassId(classId) const studentIds = await getActiveStudentIdsByClassId(classId)
@@ -99,6 +99,12 @@ export const getClassPracticeStats = cache(async (
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents, activeStudents,
} }
}
export const getClassPracticeStats = cacheFn(getClassPracticeStatsRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getClassPracticeStats"],
}) })
/** /**
@@ -106,7 +112,7 @@ export const getClassPracticeStats = cache(async (
* *
* @param classId 班级 ID * @param classId 班级 ID
*/ */
export const getClassStudentPracticeSummaries = cache(async ( export const getClassStudentPracticeSummariesRaw = async (
classId: string, classId: string,
): Promise<StudentPracticeSummary[]> => { ): Promise<StudentPracticeSummary[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId) const studentIds = await getActiveStudentIdsByClassId(classId)
@@ -138,6 +144,12 @@ export const getClassStudentPracticeSummaries = cache(async (
: 0, : 0,
lastPracticeAt: r.lastPracticeAt, lastPracticeAt: r.lastPracticeAt,
})) }))
}
export const getClassStudentPracticeSummaries = cacheFn(getClassStudentPracticeSummariesRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getClassStudentPracticeSummaries"],
}) })
/** /**
@@ -148,7 +160,7 @@ export const getClassStudentPracticeSummaries = cache(async (
* *
* @param gradeId 年级 ID * @param gradeId 年级 ID
*/ */
export const getGradePracticeStats = cache(async ( export const getGradePracticeStatsRaw = async (
gradeId: string, gradeId: string,
): Promise<{ ): Promise<{
totalSessions: number totalSessions: number
@@ -186,6 +198,12 @@ export const getGradePracticeStats = cache(async (
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents: Number(row.activeStudents), activeStudents: Number(row.activeStudents),
} }
}
export const getGradePracticeStats = cacheFn(getGradePracticeStatsRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getGradePracticeStats"],
}) })
/** /**
@@ -193,7 +211,7 @@ export const getGradePracticeStats = cache(async (
* *
* @param studentIds 学生 ID 列表 * @param studentIds 学生 ID 列表
*/ */
export const getPracticeTypeBreakdown = cache(async ( export const getPracticeTypeBreakdownRaw = async (
studentIds: string[], studentIds: string[],
): Promise<PracticeTypeBreakdown[]> => { ): Promise<PracticeTypeBreakdown[]> => {
if (studentIds.length === 0) return [] if (studentIds.length === 0) return []
@@ -218,6 +236,12 @@ export const getPracticeTypeBreakdown = cache(async (
? Number(r.correctCount) / Number(r.totalQuestions) ? Number(r.correctCount) / Number(r.totalQuestions)
: 0, : 0,
})) }))
}
export const getPracticeTypeBreakdown = cacheFn(getPracticeTypeBreakdownRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getPracticeTypeBreakdown"],
}) })
/** /**
@@ -228,7 +252,7 @@ export const getPracticeTypeBreakdown = cache(async (
* *
* @param classId 班级 ID * @param classId 班级 ID
*/ */
export const getStudentsWithoutPractice = cache(async ( export const getStudentsWithoutPracticeRaw = async (
classId: string, classId: string,
): Promise<string[]> => { ): Promise<string[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId) const studentIds = await getActiveStudentIdsByClassId(classId)
@@ -245,6 +269,12 @@ export const getStudentsWithoutPractice = cache(async (
// 返回没有练习记录的学生 // 返回没有练习记录的学生
return studentIds.filter((id) => !activeSet.has(id)) return studentIds.filter((id) => !activeSet.has(id))
}
export const getStudentsWithoutPractice = cacheFn(getStudentsWithoutPracticeRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getStudentsWithoutPractice"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -278,7 +308,7 @@ export interface TeacherClassPracticeOverview {
* *
* @param classIds 教师/年级主任可访问的班级 ID 列表 * @param classIds 教师/年级主任可访问的班级 ID 列表
*/ */
export const getTeacherClassPracticeOverviews = cache(async ( export const getTeacherClassPracticeOverviewsRaw = async (
classIds: string[], classIds: string[],
): Promise<TeacherClassPracticeOverview[]> => { ): Promise<TeacherClassPracticeOverview[]> => {
if (classIds.length === 0) return [] if (classIds.length === 0) return []
@@ -351,6 +381,12 @@ export const getTeacherClassPracticeOverviews = cache(async (
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0, participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
} }
}) })
}
export const getTeacherClassPracticeOverviews = cacheFn(getTeacherClassPracticeOverviewsRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getTeacherClassPracticeOverviews"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -378,7 +414,7 @@ export interface ClassKnowledgePointWeakness {
* @param classId 班级 ID * @param classId 班级 ID
* @param limit 返回条数(默认 10 * @param limit 返回条数(默认 10
*/ */
export const getClassKnowledgePointWeakness = cache(async ( export const getClassKnowledgePointWeaknessRaw = async (
classId: string, classId: string,
limit = 10, limit = 10,
): Promise<ClassKnowledgePointWeakness[]> => { ): Promise<ClassKnowledgePointWeakness[]> => {
@@ -418,6 +454,12 @@ export const getClassKnowledgePointWeakness = cache(async (
errorRate: totalAnswers > 0 ? wrongAnswers / totalAnswers : 0, errorRate: totalAnswers > 0 ? wrongAnswers / totalAnswers : 0,
} }
}) })
}
export const getClassKnowledgePointWeakness = cacheFn(getClassKnowledgePointWeaknessRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getClassKnowledgePointWeakness"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -447,7 +489,7 @@ export interface GradeClassPracticeComparison {
* *
* @param gradeId 年级 ID * @param gradeId 年级 ID
*/ */
export const getGradeClassPracticeComparison = cache(async ( export const getGradeClassPracticeComparisonRaw = async (
gradeId: string, gradeId: string,
): Promise<GradeClassPracticeComparison[]> => { ): Promise<GradeClassPracticeComparison[]> => {
const classList = await getClassesByGradeId(gradeId) const classList = await getClassesByGradeId(gradeId)
@@ -521,6 +563,12 @@ export const getGradeClassPracticeComparison = cache(async (
// 按参与率降序排列 // 按参与率降序排列
return results.sort((a, b) => b.participationRate - a.participationRate) return results.sort((a, b) => b.participationRate - a.participationRate)
}
export const getGradeClassPracticeComparison = cacheFn(getGradeClassPracticeComparisonRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getGradeClassPracticeComparison"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -554,7 +602,7 @@ export interface ClassLearningProfile {
* @param classId 班级 ID * @param classId 班级 ID
* @param weakKpLimit 知识点薄弱度返回条数(默认 5 * @param weakKpLimit 知识点薄弱度返回条数(默认 5
*/ */
export const getClassLearningProfile = cache(async ( export const getClassLearningProfileRaw = async (
classId: string, classId: string,
weakKpLimit = 5, weakKpLimit = 5,
): Promise<ClassLearningProfile | null> => { ): Promise<ClassLearningProfile | null> => {
@@ -584,6 +632,12 @@ export const getClassLearningProfile = cache(async (
inactiveStudentIds: inactiveIds, inactiveStudentIds: inactiveIds,
weakKnowledgePoints: weakKps, weakKnowledgePoints: weakKps,
} }
}
export const getClassLearningProfile = cacheFn(getClassLearningProfileRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getClassLearningProfile"],
}) })
/** /**
@@ -594,7 +648,7 @@ export const getClassLearningProfile = cache(async (
* *
* @param studentIds 学生 ID 列表 * @param studentIds 学生 ID 列表
*/ */
export const getStudentNameMap = cache(async ( export const getStudentNameMapRaw = async (
studentIds: string[], studentIds: string[],
): Promise<Map<string, string>> => { ): Promise<Map<string, string>> => {
if (studentIds.length === 0) return new Map() if (studentIds.length === 0) return new Map()
@@ -604,6 +658,12 @@ export const getStudentNameMap = cache(async (
result.set(id, info.name ?? "Unknown") result.set(id, info.name ?? "Unknown")
} }
return result return result
}
export const getStudentNameMap = cacheFn(getStudentNameMapRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getStudentNameMap"],
}) })
/** /**
@@ -613,10 +673,16 @@ export const getStudentNameMap = cache(async (
* *
* @param gradeId 年级 ID * @param gradeId 年级 ID
*/ */
export const getGradeClassOverviews = cache(async ( export const getGradeClassOverviewsRaw = async (
gradeId: string, gradeId: string,
): Promise<GradeClassPracticeComparison[]> => { ): Promise<GradeClassPracticeComparison[]> => {
return getGradeClassPracticeComparison(gradeId) return getGradeClassPracticeComparison(gradeId)
}
export const getGradeClassOverviews = cacheFn(getGradeClassOverviewsRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getGradeClassOverviews"],
}) })
/** /**
@@ -624,7 +690,7 @@ export const getGradeClassOverviews = cache(async (
* *
* @param gradeId 年级 ID * @param gradeId 年级 ID
*/ */
export const getGradePracticeOverview = cache(async ( export const getGradePracticeOverviewRaw = async (
gradeId: string, gradeId: string,
): Promise<{ ): Promise<{
totalClasses: number totalClasses: number
@@ -659,4 +725,10 @@ export const getGradePracticeOverview = cache(async (
totalCorrect, totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
} }
}
export const getGradePracticeOverview = cacheFn(getGradePracticeOverviewRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getGradePracticeOverview"],
}) })

View File

@@ -283,14 +283,14 @@ export async function selectQuestionsForPractice(
* 获取学生已答过的题目 ID 列表(避免重复练习)。 * 获取学生已答过的题目 ID 列表(避免重复练习)。
* *
* 从专项练习中汇总已答题目。 * 从专项练习中汇总已答题目。
* 为控制查询量,仅查询最近 1000 条记录。 * P3-8: LIMIT 从 1000 调低至 100,仅查询最近 100 条记录以控制查询量
*/ */
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> { export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
const rows = await db const rows = await db
.select({ questionId: practiceAnswers.questionId }) .select({ questionId: practiceAnswers.questionId })
.from(practiceAnswers) .from(practiceAnswers)
.where(eq(practiceAnswers.studentId, studentId)) .where(eq(practiceAnswers.studentId, studentId))
.limit(1000) .limit(100)
return Array.from(new Set(rows.map((r) => r.questionId))) return Array.from(new Set(rows.map((r) => r.questionId)))
} }

View File

@@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { and, count, desc, eq, inArray } from "drizzle-orm" import { and, count, desc, eq, inArray } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
@@ -84,7 +84,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
// 查询:练习会话列表 // 查询:练习会话列表
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getPracticeSessions = cache(async ( export const getPracticeSessionsRaw = async (
studentId: string, studentId: string,
options?: { options?: {
status?: PracticeStatus status?: PracticeStatus
@@ -128,13 +128,19 @@ export const getPracticeSessions = cache(async (
data: rows.map(mapSessionRow), data: rows.map(mapSessionRow),
total, total,
} }
}
export const getPracticeSessions = cacheFn(getPracticeSessionsRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getPracticeSessions"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 查询:练习会话详情(含答题记录) // 查询:练习会话详情(含答题记录)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getPracticeSessionById = cache(async ( export const getPracticeSessionByIdRaw = async (
sessionId: string, sessionId: string,
studentId: string, studentId: string,
): Promise<PracticeSessionDetail | null> => { ): Promise<PracticeSessionDetail | null> => {
@@ -180,13 +186,19 @@ export const getPracticeSessionById = cache(async (
sourceMeta: asPracticeSourceMeta(session.sourceMeta), sourceMeta: asPracticeSourceMeta(session.sourceMeta),
answers: mappedAnswers, answers: mappedAnswers,
} }
}
export const getPracticeSessionById = cacheFn(getPracticeSessionByIdRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getPracticeSessionById"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 查询:练习统计 // 查询:练习统计
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getPracticeStats = cache(async (studentId: string): Promise<PracticeStats> => { export const getPracticeStatsRaw = async (studentId: string): Promise<PracticeStats> => {
const rows = await db const rows = await db
.select({ .select({
practiceType: practiceSessions.practiceType, practiceType: practiceSessions.practiceType,
@@ -235,6 +247,12 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
overallAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, overallAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
byType, byType,
} }
}
export const getPracticeStats = cacheFn(getPracticeStatsRaw, {
tags: ["adaptive-practice"],
ttl: 300,
keyParts: ["adaptive-practice", "getPracticeStats"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,6 +1,5 @@
import "server-only" import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm" import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
@@ -101,11 +100,24 @@ export const buildCourseSelect = () =>
.from(electiveCourses) .from(electiveCourses)
/** /**
* 缓存科目/年级选项(单次请求复用,避免高频访问时重复查询)。 * 缓存科目/年级选项(带 TTL 与缓存标签,跨请求复用)。
* 使用 React `cache()` 在同一渲染周期内去重。
*/ */
const getCachedSubjectOptions = cache(async () => getCourseDisplayResolver().getSubjectOptions()) const getCachedSubjectOptions = cacheFn(
const getCachedGradeOptions = cache(async () => getCourseDisplayResolver().getGradeOptions()) async () => getCourseDisplayResolver().getSubjectOptions(),
{
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getCachedSubjectOptions"],
},
)
const getCachedGradeOptions = cacheFn(
async () => getCourseDisplayResolver().getGradeOptions(),
{
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getCachedGradeOptions"],
},
)
export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{ export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{
teacherNames: Map<string, string | null> teacherNames: Map<string, string | null>

View File

@@ -3,7 +3,7 @@ import "server-only"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema" import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { count, eq, desc, and, inArray, asc, type SQL } from "drizzle-orm" import { count, eq, desc, and, inArray, asc, type SQL } from "drizzle-orm"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access" import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
import { getSubjectOptions } from "@/modules/school/data-access" import { getSubjectOptions } from "@/modules/school/data-access"
import { getQuestionTypeMapByIds } from "@/modules/questions/data-access" import { getQuestionTypeMapByIds } from "@/modules/questions/data-access"
@@ -22,7 +22,7 @@ export type ExamsDashboardStats = {
examCount: number examCount: number
} }
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => { export const getExamsDashboardStatsRaw = async (scope?: DataScope): Promise<ExamsDashboardStats> => {
const conditions = [] const conditions = []
if (scope && scope.type !== "all") { if (scope && scope.type !== "all") {
@@ -59,6 +59,12 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
.where(conditions.length ? and(...conditions) : undefined) .where(conditions.length ? and(...conditions) : undefined)
return { examCount: Number(row?.value ?? 0) } return { examCount: Number(row?.value ?? 0) }
}
export const getExamsDashboardStats = cacheFn(getExamsDashboardStatsRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamsDashboardStats"],
}) })
/** /**
@@ -270,8 +276,7 @@ export const addExamQuestions = async (
* 年级仪表盘 - 维度3获取年级下所有考试 + 提交统计。 * 年级仪表盘 - 维度3获取年级下所有考试 + 提交统计。
* exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分。 * exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分。
*/ */
export const getExamsByGradeId = cache( export const getExamsByGradeIdRaw = async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
const conditions: SQL[] = [eq(exams.gradeId, params.gradeId)] const conditions: SQL[] = [eq(exams.gradeId, params.gradeId)]
// scope 过滤 // scope 过滤
@@ -387,13 +392,17 @@ export const getExamsByGradeId = cache(
return { gradeId: params.gradeId, exams: items, totals } return { gradeId: params.gradeId, exams: items, totals }
} }
)
export const getExamsByGradeId = cacheFn(getExamsByGradeIdRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamsByGradeId"],
})
/** /**
* 获取可用于成绩录入的试卷列表(按 scope 过滤,只返回有题目的试卷)。 * 获取可用于成绩录入的试卷列表(按 scope 过滤,只返回有题目的试卷)。
*/ */
export const getExamsForGradeEntry = cache( export const getExamsForGradeEntryRaw = async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
const conditions: SQL[] = [] const conditions: SQL[] = []
if (scope.type === "owned") { if (scope.type === "owned") {
@@ -449,13 +458,17 @@ export const getExamsForGradeEntry = cache(
} }
}) })
} }
)
export const getExamsForGradeEntry = cacheFn(getExamsForGradeEntryRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamsForGradeEntry"],
})
/** /**
* 获取单个试卷详情(含题目列表),用于成绩录入表格表头。 * 获取单个试卷详情(含题目列表),用于成绩录入表格表头。
*/ */
export const getExamForGradeEntry = cache( export const getExamForGradeEntryRaw = async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
const exam = await db.query.exams.findFirst({ const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId), where: eq(exams.id, examId),
with: { subject: true, gradeEntity: true }, with: { subject: true, gradeEntity: true },
@@ -511,4 +524,9 @@ export const getExamForGradeEntry = cache(
})), })),
} }
} }
)
export const getExamForGradeEntry = cacheFn(getExamForGradeEntryRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamForGradeEntry"],
})

View File

@@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, sql } from "drizzle-orm" import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
@@ -69,8 +69,7 @@ const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
updatedAt: r.updatedAt.toISOString(), updatedAt: r.updatedAt.toISOString(),
}) })
export const getGradeRecords = cache( export const getGradeRecordsRaw = async (
async (
params: GradeQueryParams & { params: GradeQueryParams & {
scope: DataScope scope: DataScope
currentUserId?: string currentUserId?: string
@@ -146,11 +145,22 @@ export const getGradeRecords = cache(
return { records, total } return { records, total }
} }
)
export const getGradeRecordById = cache(async (id: string): Promise<GradeRecord | null> => { export const getGradeRecords = cacheFn(getGradeRecordsRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getGradeRecords"],
})
export const getGradeRecordByIdRaw = async (id: string): Promise<GradeRecord | null> => {
const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1) const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
return row ? serializeRecord(row) : null return row ? serializeRecord(row) : null
}
export const getGradeRecordById = cacheFn(getGradeRecordByIdRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getGradeRecordById"],
}) })
export async function createGradeRecord( export async function createGradeRecord(
@@ -289,8 +299,7 @@ export async function bulkDeleteGradeRecords(ids: string[]): Promise<number> {
return existingIds.length return existingIds.length
} }
export const getClassGradeStats = cache( export const getClassGradeStatsRaw = async (
async (
classId: string, classId: string,
subjectId?: string, subjectId?: string,
examId?: string, examId?: string,
@@ -317,7 +326,12 @@ export const getClassGradeStats = cache(
return computeGradeStats(rows) return computeGradeStats(rows)
} }
)
export const getClassGradeStats = cacheFn(getClassGradeStatsRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassGradeStats"],
})
/** /**
* P2-4: 计算单个学生在班级中的排名。 * P2-4: 计算单个学生在班级中的排名。
@@ -373,8 +387,7 @@ export async function getStudentRankInClass(
return distinctHigherAvgs.size + 1 return distinctHigherAvgs.size + 1
} }
export const getStudentGradeSummary = cache( export const getStudentGradeSummaryRaw = async (
async (
studentId: string, studentId: string,
scope?: DataScope scope?: DataScope
): Promise<StudentGradeSummary | null> => { ): Promise<StudentGradeSummary | null> => {
@@ -468,10 +481,14 @@ export const getStudentGradeSummary = cache(
rank, rank,
} }
} }
)
export const getClassRanking = cache( export const getStudentGradeSummary = cacheFn(getStudentGradeSummaryRaw, {
async ( tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getStudentGradeSummary"],
})
export const getClassRankingRaw = async (
classId: string, classId: string,
subjectId?: string, subjectId?: string,
examId?: string, examId?: string,
@@ -529,10 +546,14 @@ export const getClassRanking = cache(
return ranked return ranked
} }
)
export const getClassStudentsForEntry = cache( export const getClassRanking = cacheFn(getClassRankingRaw, {
async ( tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassRanking"],
})
export const getClassStudentsForEntryRaw = async (
classId: string, classId: string,
scope?: DataScope scope?: DataScope
): Promise<Array<{ id: string; name: string; email: string }>> => { ): Promise<Array<{ id: string; name: string; email: string }>> => {
@@ -559,10 +580,14 @@ export const getClassStudentsForEntry = cache(
}) })
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
} }
)
export const getClassGradeStatsWithMeta = cache( export const getClassStudentsForEntry = cacheFn(getClassStudentsForEntryRaw, {
async ( tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassStudentsForEntry"],
})
export const getClassGradeStatsWithMetaRaw = async (
classId: string, classId: string,
subjectId?: string, subjectId?: string,
examId?: string, examId?: string,
@@ -601,4 +626,9 @@ export const getClassGradeStatsWithMeta = cache(
studentCount: activeStudentIds.length, studentCount: activeStudentIds.length,
} }
} }
)
export const getClassGradeStatsWithMeta = cacheFn(getClassGradeStatsWithMetaRaw, {
tags: ["grades"],
ttl: 300,
keyParts: ["grades", "getClassGradeStatsWithMeta"],
})