Files
NextEdu/src/modules/parent/data-access.ts
SpecialX a60105455e feat(exams,homework,parent): V3 审计深度修复 — 批量批改/考试分析/提交反馈/家长视图/移动端优化
V3-5: exam-actions.tsx 集成 useExamHomeworkFeatures hook,按角色控制菜单项可见性
V3-7: 批量批改 — 新增 batchAutoGradeSubmissions data-access + Server Action + HomeworkBatchGradingView 组件
V3-8: 考试分析仪表盘 — 新增 getExamAnalytics stats-service + ExamAnalyticsDashboard 组件 + /teacher/exams/[id]/analytics 路由
V3-9: 提交后即时反馈页 — 新增 HomeworkSubmissionResult 组件 + /student/learning/assignments/[id]/result 路由
V3-11: 家长考试详情 — 新增 ChildExamDetail 组件 + getStudentExamResults data-access + child-detail-panel exams Tab
V3-12: 移动端触控优化 — 题目导航与考试操作按钮 44px 最小触控目标

修复: instrumentation.ts 适配器补全 questionCount/averageScore/overdueCount 字段
修复: exam-homework-port.ts 类型导入对齐 ExamWithQuestionsForHomework
修复: trend-line-chart.tsx 数据类型允许 undefined(classAverage 可选场景)

同步更新 004/005 架构文档
2026-06-23 01:06:27 +08:00

270 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { cache } from "react"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { parentStudentRelations } from "@/shared/db/schema"
import {
getStudentActiveClass,
getStudentClasses,
getStudentSchedule,
} from "@/modules/classes/data-access"
import {
getStudentDashboardGrades,
getStudentHomeworkAssignments,
getStudentExamResults,
} from "@/modules/homework/data-access"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { getGradeNameById } from "@/modules/school/data-access"
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
import type {
ChildBasicInfo,
ChildDashboardData,
ChildHomeworkSummaryData,
ChildScheduleItem,
ChildWeeklyScheduleItem,
ParentChildRelation,
ParentDashboardData,
} from "./types"
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
const isWeekday = (n: number): n is Weekday => n >= 1 && n <= 7
const toWeekday = (d: Date): Weekday => {
const day = d.getDay()
// getDay() returns 0 (Sun) - 6 (Sat); normalize Sunday (0) to 7
const normalized = day === 0 ? 7 : day
return isWeekday(normalized) ? normalized : 1
}
export const getChildren = cache(async (parentId: string): Promise<ParentChildRelation[]> => {
const id = parentId.trim()
if (!id) return []
const rows = await db
.select({
id: parentStudentRelations.id,
parentId: parentStudentRelations.parentId,
studentId: parentStudentRelations.studentId,
relation: parentStudentRelations.relation,
createdAt: parentStudentRelations.createdAt,
})
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, id))
.orderBy(asc(parentStudentRelations.createdAt))
return rows.map((r) => ({
id: r.id,
parentId: r.parentId,
studentId: r.studentId,
relation: r.relation,
createdAt: r.createdAt.toISOString(),
}))
})
/**
* 校验家长与子女的关系是否存在,并返回关系类型。
* 同时按 parentId 与 studentId 过滤,防止跨家庭信息泄露。
*/
export const verifyParentChildRelation = cache(
async (studentId: string, parentId: string): Promise<string | null> => {
const [row] = await db
.select({ relation: parentStudentRelations.relation })
.from(parentStudentRelations)
.where(
and(
eq(parentStudentRelations.studentId, studentId),
eq(parentStudentRelations.parentId, parentId),
),
)
.limit(1)
return row?.relation ?? null
},
)
export const getChildBasicInfo = cache(
async (
studentId: string,
relation: string | null = null,
): Promise<ChildBasicInfo | null> => {
const student = await getUserBasicInfo(studentId)
if (!student) return null
// gradeName 与 activeClass 相互独立,并行拉取
const [gradeName, activeClass] = await Promise.all([
student.gradeId ? getGradeNameById(student.gradeId) : Promise.resolve(null),
getStudentActiveClass(studentId),
])
return {
id: student.id,
name: student.name,
email: student.email,
image: student.image,
gradeName,
className: activeClass?.className ?? null,
classId: activeClass?.classId ?? null,
relation,
}
},
)
const buildHomeworkSummary = (
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>,
): ChildHomeworkSummaryData => {
const now = new Date()
let pendingCount = 0
let submittedCount = 0
let gradedCount = 0
let overdueCount = 0
for (const a of assignments) {
if (a.progressStatus === "graded") {
gradedCount += 1
continue
}
if (a.progressStatus === "submitted") {
submittedCount += 1
}
if (a.progressStatus === "not_started" || a.progressStatus === "in_progress") {
pendingCount += 1
}
if (a.dueAt) {
const due = new Date(a.dueAt)
if (due < now && a.progressStatus !== "submitted") {
overdueCount += 1
}
}
}
const recentAssignments = assignments
.toSorted((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, 5)
return {
pendingCount,
submittedCount,
gradedCount,
overdueCount,
recentAssignments,
}
}
const buildTodaySchedule = (
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
): ChildScheduleItem[] => {
const todayWeekday = toWeekday(new Date())
return schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
}
const buildWeeklySchedule = (
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
): ChildWeeklyScheduleItem[] => {
return schedule
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
weekday: s.weekday,
}))
.sort((a, b) =>
a.weekday === b.weekday
? a.startTime.localeCompare(b.startTime)
: a.weekday - b.weekday,
)
}
export const getChildDashboardData = cache(
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
const basicInfo = await getChildBasicInfo(studentId, relation)
if (!basicInfo) return null
const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary, examResults] = await Promise.all([
getStudentClasses(studentId),
getStudentSchedule(studentId),
getStudentHomeworkAssignments(studentId),
getStudentDashboardGrades(studentId),
getStudentGradeSummary(studentId),
getStudentExamResults(studentId),
])
return {
basicInfo,
enrolledClasses,
todaySchedule: buildTodaySchedule(schedule),
weeklySchedule: buildWeeklySchedule(schedule),
homeworkSummary: buildHomeworkSummary(assignments),
gradeTrend,
gradeSummary,
examResults,
}
},
)
export const getParentDashboardData = cache(
async (parentId: string): Promise<ParentDashboardData> => {
const id = parentId.trim()
if (!id) return { parentName: null, children: [] }
const [parentInfo, relations] = await Promise.all([
getUserNamesByIds([id]),
getChildren(id),
])
const parentName = parentInfo.get(id)?.name ?? null
if (relations.length === 0) {
return { parentName, children: [] }
}
const children = await Promise.all(
relations.map((r) => getChildDashboardData(r.studentId, r.relation)),
)
return {
parentName,
children: children.filter((c): c is ChildDashboardData => c !== null),
}
},
)
/**
* 获取家长所有子女的轻量列表id + name用于详情页头部多子女切换器。
* 一次批量查询,避免 N+1。
*/
export const getChildNameList = cache(
async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
const relations = await getChildren(parentId)
if (relations.length === 0) return []
const nameMap = await getUserNamesByIds(relations.map((r) => r.studentId))
return relations.map((r) => ({
id: r.studentId,
name: nameMap.get(r.studentId)?.name ?? null,
}))
},
)