feat(P2): 实现选课管理、考试监考、学情诊断三大功能模块
## 新增功能模块 ### 1. 选课管理(elective) - 新增表:electiveCourses、courseSelections - 新增权限:ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT - 支持先到先得 + 抽签两种选课模式 - admin/teacher/student 三端页面 ### 2. 考试监考(proctoring) - exams 表扩展:examMode/durationMinutes/antiCheatEnabled 等字段 - 新增表:examProctoringEvents - 新增权限:EXAM_PROCTOR/EXAM_PROCTOR_READ - 教师监考面板 + 学生端防作弊监控 - API:/api/proctoring/event 接收事件上报 ### 3. 学情诊断报告(diagnostic) - 新增表:knowledgePointMastery、learningDiagnosticReports - 新增权限:DIAGNOSTIC_MANAGE/DIAGNOSTIC_READ - 基于提交答案自动计算知识点掌握度 - 生成个人/班级诊断报告(强项/弱项/建议) - 雷达图可视化 ## 其他改动 - 项目规则:单文件行数限制从 300 行调整为企业级规范(组件≤500/Actions≤800/硬上限1000) - scripts/seed.ts:消除全部 any 类型,定义内部类型,0 lint 错误 - 架构文档 004/005 同步更新三个新模块 - 迁移文件 0001_heavy_sage.sql 生成 ## 验证 - npx tsc --noEmit:0 错误 - npm run lint:0 错误 0 警告
This commit is contained in:
41
src/app/(dashboard)/admin/elective/[id]/edit/page.tsx
Normal file
41
src/app/(dashboard)/admin/elective/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getElectiveCourseById, getSubjectOptions } from "@/modules/elective/data-access"
|
||||
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditElectiveCoursePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
|
||||
const [course, subjects, grades, teachers] = await Promise.all([
|
||||
getElectiveCourseById(id),
|
||||
getSubjectOptions(),
|
||||
getGrades(),
|
||||
getStaffOptions(),
|
||||
])
|
||||
|
||||
if (!course) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Edit Elective Course</h2>
|
||||
<p className="text-muted-foreground">Update the elective course details below.</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="edit"
|
||||
course={course}
|
||||
subjects={subjects}
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||
backHref="/admin/elective"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/app/(dashboard)/admin/elective/create/page.tsx
Normal file
29
src/app/(dashboard)/admin/elective/create/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/elective/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateElectiveCoursePage() {
|
||||
const [subjects, grades, teachers] = await Promise.all([
|
||||
getSubjectOptions(),
|
||||
getGrades(),
|
||||
getStaffOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">New Elective Course</h2>
|
||||
<p className="text-muted-foreground">Create a new elective course.</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="create"
|
||||
subjects={subjects}
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||
backHref="/admin/elective"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/app/(dashboard)/admin/elective/page.tsx
Normal file
44
src/app/(dashboard)/admin/elective/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
|
||||
|
||||
export default async function AdminElectivePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const courses = await getElectiveCourses({ status })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage elective courses, open/close selection, and run lottery.
|
||||
</p>
|
||||
</div>
|
||||
<ElectiveCourseList
|
||||
courses={courses}
|
||||
canManage
|
||||
createHref="/admin/elective/create"
|
||||
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/student/diagnostic/page.tsx
Normal file
31
src/app/(dashboard)/student/diagnostic/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentDiagnosticPage() {
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const [summary, reports] = await Promise.all([
|
||||
getStudentMasterySummary(ctx.userId),
|
||||
getDiagnosticReports({ studentId: ctx.userId }),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" />
|
||||
My Diagnostic
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Your knowledge point mastery analysis and diagnostic reports.
|
||||
</p>
|
||||
</div>
|
||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/app/(dashboard)/student/elective/page.tsx
Normal file
49
src/app/(dashboard)/student/elective/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { auth } from "@/auth"
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentElectivePage() {
|
||||
const session = await auth()
|
||||
const studentId = String(session?.user?.id ?? "")
|
||||
|
||||
if (!studentId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||
<p className="text-muted-foreground">Browse and select elective courses.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="Sign in required"
|
||||
description="Please sign in to view elective courses."
|
||||
icon={Inbox}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [availableCourses, mySelections] = await Promise.all([
|
||||
getAvailableCoursesForStudent(studentId),
|
||||
getStudentSelections(studentId),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Browse available electives and manage your selections.
|
||||
</p>
|
||||
</div>
|
||||
<StudentSelectionView
|
||||
availableCourses={availableCourses}
|
||||
mySelections={mySelections}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ClassDiagnosticPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ classId: string }>
|
||||
}) {
|
||||
const { classId } = await params
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
|
||||
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {
|
||||
notFound()
|
||||
}
|
||||
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const summary = await getClassMasterySummary(classId)
|
||||
|
||||
if (!summary) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" />
|
||||
Class Diagnostic
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Class-level knowledge point mastery overview and student attention list.
|
||||
</p>
|
||||
</div>
|
||||
<ClassDiagnosticView summary={summary} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/app/(dashboard)/teacher/diagnostic/page.tsx
Normal file
48
src/app/(dashboard)/teacher/diagnostic/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { ReportList } from "@/modules/diagnostic/components/report-list"
|
||||
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
export default async function TeacherDiagnosticPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const reportType = getParam(sp, "reportType")
|
||||
const status = getParam(sp, "status")
|
||||
|
||||
const reports = await getDiagnosticReports({
|
||||
reportType: reportType && reportType !== "all" ? (reportType as DiagnosticReportType) : undefined,
|
||||
status: status && status !== "all" ? (status as DiagnosticReportStatus) : undefined,
|
||||
})
|
||||
|
||||
// 学生角色仅查看自己的报告;其他角色查看全部
|
||||
const visibleReports =
|
||||
ctx.dataScope.type === "class_members"
|
||||
? reports.filter((r) => r.studentId === ctx.userId)
|
||||
: reports
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Learning Diagnostic</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage diagnostic reports based on knowledge point mastery.
|
||||
</p>
|
||||
</div>
|
||||
<ReportList reports={visibleReports} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import {
|
||||
getStudentMasterySummary,
|
||||
getKnowledgePointStats,
|
||||
} from "@/modules/diagnostic/data-access"
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentDiagnosticPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ studentId: string }>
|
||||
}) {
|
||||
const { studentId } = await params
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
// DataScope 二次校验:学生只能看自己,家长只能看子女
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
notFound()
|
||||
}
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const [summary, reports] = await Promise.all([
|
||||
getStudentMasterySummary(studentId),
|
||||
getDiagnosticReports({ studentId }),
|
||||
])
|
||||
|
||||
// 班级平均掌握度(用于雷达图对比)
|
||||
let classAverageMastery: MasteryRadarPoint[] | undefined
|
||||
if (summary) {
|
||||
// 通过学生所在班级获取班级平均
|
||||
const classStats = await getKnowledgePointStats()
|
||||
classAverageMastery = classStats.map((k) => ({
|
||||
knowledgePoint: k.knowledgePointName,
|
||||
student: 0,
|
||||
classAverage: k.averageMastery,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" />
|
||||
Student Diagnostic
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Knowledge point mastery analysis and diagnostic reports.
|
||||
</p>
|
||||
</div>
|
||||
<StudentDiagnosticView
|
||||
summary={summary}
|
||||
reports={reports}
|
||||
classAverageMastery={classAverageMastery}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/app/(dashboard)/teacher/elective/page.tsx
Normal file
50
src/app/(dashboard)/teacher/elective/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { auth } from "@/auth"
|
||||
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
const getParam = (params: SearchParams, key: string) => {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
|
||||
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
|
||||
|
||||
export default async function TeacherElectivePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const session = await auth()
|
||||
const teacherId = String(session?.user?.id ?? "")
|
||||
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
const courses = teacherId
|
||||
? await getElectiveCourses({ teacherId, status })
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Elective Courses</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage the elective courses you teach.
|
||||
</p>
|
||||
</div>
|
||||
<ElectiveCourseList
|
||||
courses={courses}
|
||||
canManage
|
||||
createHref="/admin/elective/create"
|
||||
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx
Normal file
55
src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard"
|
||||
import {
|
||||
getExamForProctoring,
|
||||
getExamProctoringSummary,
|
||||
getStudentProctoringStatuses,
|
||||
getRecentProctoringEvents,
|
||||
} from "@/modules/proctoring/data-access"
|
||||
import type { ProctoringDashboardData } from "@/modules/proctoring/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ExamProctoringPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
try {
|
||||
await requirePermission(Permissions.EXAM_PROCTOR)
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
您没有监考权限(exam:proctor)。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const exam = await getExamForProctoring(id)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// 并行拉取面板初始数据
|
||||
const [summary, students, recentEvents] = await Promise.all([
|
||||
getExamProctoringSummary(id),
|
||||
getStudentProctoringStatuses(id),
|
||||
getRecentProctoringEvents(id, 20),
|
||||
])
|
||||
|
||||
const initialData: ProctoringDashboardData = {
|
||||
summary,
|
||||
students,
|
||||
recentEvents,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<ProctoringDashboard examId={id} initialData={initialData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user