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"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
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"
/** 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 {
data: ClassKnowledgePointWeakness[]
className?: string
@@ -41,6 +62,36 @@ export function ClassKnowledgePointWeaknessChart({
}: ClassKnowledgePointWeaknessChartProps) {
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) {
return (
<Card className={cn("shadow-none", className)}>
@@ -82,56 +133,25 @@ export function ClassKnowledgePointWeaknessChart({
</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} />
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
{...X_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<YAxis
tickLine={false}
axisLine={false}
width={40}
tickFormatter={(value: number) => `${value}%`}
{...Y_AXIS_BASE_PROPS}
tickFormatter={formatPercentTick}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
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>
)
}}
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="errorRate" radius={[4, 4, 0, 0]}>
<Bar dataKey="errorRate" radius={BAR_RADIUS}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}

View File

@@ -1,5 +1,6 @@
"use client"
import { useCallback } from "react"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
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"
/** 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 {
data: PracticeTypeBreakdown[]
className?: string
@@ -39,6 +52,41 @@ export function PracticeTypeBreakdownChart({
}: PracticeTypeBreakdownChartProps) {
const t = useTranslations("practice")
const renderTooltip = useCallback(
(payload: unknown) => {
// 从 unknown 转换recharts tooltip payload 类型未知,需类型断言
const p = payload as {
name: string
sessionCount: number
totalQuestions: number
correctCount: number
accuracy: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.totalSessions")}
<span className="font-medium text-foreground">{p.sessionCount}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.totalAnswered")}
<span className="font-medium text-foreground">{p.totalQuestions}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.wrongAnswers")}
<span className="font-medium text-rose-600">{p.totalQuestions - p.correctCount}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.averageAccuracy")}
<span className="font-medium text-emerald-600">{p.accuracy}%</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -64,53 +112,19 @@ export function PracticeTypeBreakdownChart({
</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} />
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis {...X_AXIS_PROPS} />
<YAxis {...Y_AXIS_PROPS} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
sessionCount: number
totalQuestions: number
correctCount: number
accuracy: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.totalSessions")}
<span className="font-medium text-foreground">{p.sessionCount}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.totalAnswered")}
<span className="font-medium text-foreground">{p.totalQuestions}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.knowledgePointWeakness.wrongAnswers")}
<span className="font-medium text-rose-600">{p.totalQuestions - p.correctCount}</span>
</div>
<div className="text-muted-foreground">
{t("teacher.classComparison.averageAccuracy")}
<span className="font-medium text-emerald-600">{p.accuracy}%</span>
</div>
</div>
)
}}
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="sessionCount" radius={[4, 4, 0, 0]}>
<Bar dataKey="sessionCount" radius={BAR_RADIUS}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -64,7 +64,7 @@ export interface PracticeTypeBreakdown {
*
* @param classId 班级 ID
*/
export const getClassPracticeStats = cache(async (
export const getClassPracticeStatsRaw = async (
classId: string,
): Promise<ClassPracticeStats | null> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
@@ -99,6 +99,12 @@ export const getClassPracticeStats = cache(async (
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
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
*/
export const getClassStudentPracticeSummaries = cache(async (
export const getClassStudentPracticeSummariesRaw = async (
classId: string,
): Promise<StudentPracticeSummary[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
@@ -138,6 +144,12 @@ export const getClassStudentPracticeSummaries = cache(async (
: 0,
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
*/
export const getGradePracticeStats = cache(async (
export const getGradePracticeStatsRaw = async (
gradeId: string,
): Promise<{
totalSessions: number
@@ -186,6 +198,12 @@ export const getGradePracticeStats = cache(async (
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
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 列表
*/
export const getPracticeTypeBreakdown = cache(async (
export const getPracticeTypeBreakdownRaw = async (
studentIds: string[],
): Promise<PracticeTypeBreakdown[]> => {
if (studentIds.length === 0) return []
@@ -218,6 +236,12 @@ export const getPracticeTypeBreakdown = cache(async (
? Number(r.correctCount) / Number(r.totalQuestions)
: 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
*/
export const getStudentsWithoutPractice = cache(async (
export const getStudentsWithoutPracticeRaw = async (
classId: string,
): Promise<string[]> => {
const studentIds = await getActiveStudentIdsByClassId(classId)
@@ -245,6 +269,12 @@ export const getStudentsWithoutPractice = cache(async (
// 返回没有练习记录的学生
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 列表
*/
export const getTeacherClassPracticeOverviews = cache(async (
export const getTeacherClassPracticeOverviewsRaw = async (
classIds: string[],
): Promise<TeacherClassPracticeOverview[]> => {
if (classIds.length === 0) return []
@@ -351,6 +381,12 @@ export const getTeacherClassPracticeOverviews = cache(async (
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 limit 返回条数(默认 10
*/
export const getClassKnowledgePointWeakness = cache(async (
export const getClassKnowledgePointWeaknessRaw = async (
classId: string,
limit = 10,
): Promise<ClassKnowledgePointWeakness[]> => {
@@ -418,6 +454,12 @@ export const getClassKnowledgePointWeakness = cache(async (
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
*/
export const getGradeClassPracticeComparison = cache(async (
export const getGradeClassPracticeComparisonRaw = async (
gradeId: string,
): Promise<GradeClassPracticeComparison[]> => {
const classList = await getClassesByGradeId(gradeId)
@@ -521,6 +563,12 @@ export const getGradeClassPracticeComparison = cache(async (
// 按参与率降序排列
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 weakKpLimit 知识点薄弱度返回条数(默认 5
*/
export const getClassLearningProfile = cache(async (
export const getClassLearningProfileRaw = async (
classId: string,
weakKpLimit = 5,
): Promise<ClassLearningProfile | null> => {
@@ -584,6 +632,12 @@ export const getClassLearningProfile = cache(async (
inactiveStudentIds: inactiveIds,
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 列表
*/
export const getStudentNameMap = cache(async (
export const getStudentNameMapRaw = async (
studentIds: string[],
): Promise<Map<string, string>> => {
if (studentIds.length === 0) return new Map()
@@ -604,6 +658,12 @@ export const getStudentNameMap = cache(async (
result.set(id, info.name ?? "Unknown")
}
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
*/
export const getGradeClassOverviews = cache(async (
export const getGradeClassOverviewsRaw = async (
gradeId: string,
): Promise<GradeClassPracticeComparison[]> => {
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
*/
export const getGradePracticeOverview = cache(async (
export const getGradePracticeOverviewRaw = async (
gradeId: string,
): Promise<{
totalClasses: number
@@ -659,4 +725,10 @@ export const getGradePracticeOverview = cache(async (
totalCorrect,
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 列表(避免重复练习)。
*
* 从专项练习中汇总已答题目。
* 为控制查询量,仅查询最近 1000 条记录。
* P3-8: LIMIT 从 1000 调低至 100,仅查询最近 100 条记录以控制查询量
*/
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
const rows = await db
.select({ questionId: practiceAnswers.questionId })
.from(practiceAnswers)
.where(eq(practiceAnswers.studentId, studentId))
.limit(1000)
.limit(100)
return Array.from(new Set(rows.map((r) => r.questionId)))
}

View File

@@ -1,6 +1,6 @@
import "server-only"
import { cache } from "react"
import { cacheFn } from "@/shared/lib/cache"
import { and, count, desc, eq, inArray } from "drizzle-orm"
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,
options?: {
status?: PracticeStatus
@@ -128,13 +128,19 @@ export const getPracticeSessions = cache(async (
data: rows.map(mapSessionRow),
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,
studentId: string,
): Promise<PracticeSessionDetail | null> => {
@@ -180,13 +186,19 @@ export const getPracticeSessionById = cache(async (
sourceMeta: asPracticeSourceMeta(session.sourceMeta),
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
.select({
practiceType: practiceSessions.practiceType,
@@ -235,6 +247,12 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
overallAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
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 { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
@@ -101,11 +100,24 @@ export const buildCourseSelect = () =>
.from(electiveCourses)
/**
* 缓存科目/年级选项(单次请求复用,避免高频访问时重复查询)。
* 使用 React `cache()` 在同一渲染周期内去重。
* 缓存科目/年级选项(带 TTL 与缓存标签,跨请求复用)。
*/
const getCachedSubjectOptions = cache(async () => getCourseDisplayResolver().getSubjectOptions())
const getCachedGradeOptions = cache(async () => getCourseDisplayResolver().getGradeOptions())
const getCachedSubjectOptions = cacheFn(
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<{
teacherNames: Map<string, string | null>

View File

@@ -3,7 +3,7 @@ import "server-only"
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
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 { getSubjectOptions } from "@/modules/school/data-access"
import { getQuestionTypeMapByIds } from "@/modules/questions/data-access"
@@ -22,7 +22,7 @@ export type ExamsDashboardStats = {
examCount: number
}
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => {
export const getExamsDashboardStatsRaw = async (scope?: DataScope): Promise<ExamsDashboardStats> => {
const conditions = []
if (scope && scope.type !== "all") {
@@ -59,6 +59,12 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
.where(conditions.length ? and(...conditions) : undefined)
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获取年级下所有考试 + 提交统计。
* exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分。
*/
export const getExamsByGradeId = cache(
async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
export const getExamsByGradeIdRaw = async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
const conditions: SQL[] = [eq(exams.gradeId, params.gradeId)]
// scope 过滤
@@ -387,13 +392,17 @@ export const getExamsByGradeId = cache(
return { gradeId: params.gradeId, exams: items, totals }
}
)
export const getExamsByGradeId = cacheFn(getExamsByGradeIdRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamsByGradeId"],
})
/**
* 获取可用于成绩录入的试卷列表(按 scope 过滤,只返回有题目的试卷)。
*/
export const getExamsForGradeEntry = cache(
async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
export const getExamsForGradeEntryRaw = async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
const conditions: SQL[] = []
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(
async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
export const getExamForGradeEntryRaw = async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: { subject: true, gradeEntity: true },
@@ -508,7 +521,12 @@ export const getExamForGradeEntry = cache(
order: q.order ?? 0,
score: q.score ?? 0,
type: questionTypeMap.get(q.questionId) ?? "text",
})),
}
})),
}
)
}
export const getExamForGradeEntry = cacheFn(getExamForGradeEntryRaw, {
tags: ["exams"],
ttl: 300,
keyParts: ["exams", "getExamForGradeEntry"],
})

View File

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