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 架构文档
270 lines
7.4 KiB
TypeScript
270 lines
7.4 KiB
TypeScript
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,
|
||
}))
|
||
},
|
||
)
|