refactor(grades,diagnostic): 成绩和学情诊断模块审计修复
P0-1: 10 个页面补充 requirePermission 权限校验 P0-2: diagnostic/data-access-reports.ts 移除直查 users 表,改用 getUserNamesByIds P0-3: 新增 grade/grades/diagnostic 三组 i18n 翻译文件(zh-CN/en) P0-4: 新增 /management/grade 重定向页面 P1-2: 抽取 toNumber/normalize/buildScopeClassFilter 到 lib/grade-utils.ts P1-3: 为 12 个 Action 新增 Zod safeParse 校验(schema.ts +12 查询 schema) P1-4: 修复 as 断言违规,改用类型守卫函数 P2-2: 移除 diagnostic 组件中 Tailwind 任意值 同步更新架构图文档 004 和 005
This commit is contained in:
@@ -15,6 +15,13 @@ import {
|
||||
type SubjectComparisonParams,
|
||||
} from "./data-access-analytics"
|
||||
import { getRankingTrend } from "./data-access-ranking"
|
||||
import {
|
||||
ClassComparisonQuerySchema,
|
||||
GradeDistributionQuerySchema,
|
||||
GradeTrendQuerySchema,
|
||||
RankingTrendQuerySchema,
|
||||
SubjectComparisonQuerySchema,
|
||||
} from "./schema"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionResult,
|
||||
@@ -28,8 +35,18 @@ export async function getGradeTrendAction(
|
||||
): Promise<ActionState<GradeTrendResult | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = GradeTrendQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getGradeTrend({
|
||||
...params,
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
@@ -48,8 +65,18 @@ export async function getClassComparisonAction(
|
||||
): Promise<ActionState<ClassComparisonItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = ClassComparisonQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getClassComparison({
|
||||
...params,
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
@@ -67,8 +94,18 @@ export async function getSubjectComparisonAction(
|
||||
): Promise<ActionState<SubjectComparisonItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = SubjectComparisonQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getSubjectComparison({
|
||||
...params,
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
@@ -86,8 +123,18 @@ export async function getGradeDistributionAction(
|
||||
): Promise<ActionState<GradeDistributionResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = GradeDistributionQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getGradeDistribution({
|
||||
...params,
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
@@ -109,19 +156,32 @@ export async function getRankingTrendAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = RankingTrendQuerySchema.safeParse({ studentId, subjectId, semester })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Students can only view their own ranking trend
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||
return { success: false, message: "Can only view your own ranking trend" }
|
||||
}
|
||||
// Parents can only view their children's ranking trend
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(studentId)
|
||||
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||
) {
|
||||
return { success: false, message: "Can only view your children's ranking trend" }
|
||||
}
|
||||
|
||||
const result = await getRankingTrend(studentId, subjectId, semester)
|
||||
const result = await getRankingTrend(
|
||||
parsed.data.studentId,
|
||||
parsed.data.subjectId,
|
||||
parsed.data.semester
|
||||
)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
CreateGradeRecordSchema,
|
||||
BatchCreateGradeRecordSchema,
|
||||
UpdateGradeRecordSchema,
|
||||
DeleteGradeRecordSchema,
|
||||
GetGradeRecordByIdSchema,
|
||||
GradeQuerySchema,
|
||||
ClassGradeStatsQuerySchema,
|
||||
StudentGradeSummaryQuerySchema,
|
||||
ClassRankingQuerySchema,
|
||||
ExportGradesSchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
createGradeRecord,
|
||||
@@ -156,7 +163,17 @@ export async function deleteGradeRecordAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
await deleteGradeRecord(id)
|
||||
|
||||
const parsed = DeleteGradeRecordSchema.safeParse({ id })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid id",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await deleteGradeRecord(parsed.data.id)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record deleted" }
|
||||
} catch (e) {
|
||||
@@ -173,8 +190,18 @@ export async function getGradeRecordsAction(
|
||||
): Promise<ActionState<GradeRecordListItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = GradeQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const records = await getGradeRecords({
|
||||
...params,
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
@@ -195,7 +222,21 @@ export async function getClassGradeStatsAction(
|
||||
): Promise<ActionState<GradeStats | null>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const result = await getClassGradeStatsWithMeta(classId, subjectId, examId)
|
||||
|
||||
const parsed = ClassGradeStatsQuerySchema.safeParse({ classId, subjectId, examId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getClassGradeStatsWithMeta(
|
||||
parsed.data.classId,
|
||||
parsed.data.subjectId,
|
||||
parsed.data.examId
|
||||
)
|
||||
return { success: true, data: result?.stats ?? null }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
@@ -212,14 +253,26 @@ export async function getStudentGradeSummaryAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
const parsed = StudentGradeSummaryQuerySchema.safeParse({ studentId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid student id",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||
return { success: false, message: "Can only view your own grades" }
|
||||
}
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||
) {
|
||||
return { success: false, message: "Can only view your children's grades" }
|
||||
}
|
||||
|
||||
const summary = await getStudentGradeSummary(studentId)
|
||||
const summary = await getStudentGradeSummary(parsed.data.studentId)
|
||||
return { success: true, data: summary }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
@@ -237,7 +290,21 @@ export async function getClassRankingAction(
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const ranking = await getClassRanking(classId, subjectId, examId)
|
||||
|
||||
const parsed = ClassRankingQuerySchema.safeParse({ classId, subjectId, examId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const ranking = await getClassRanking(
|
||||
parsed.data.classId,
|
||||
parsed.data.subjectId,
|
||||
parsed.data.examId
|
||||
)
|
||||
return { success: true, data: ranking }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
@@ -253,7 +320,17 @@ export async function getGradeRecordByIdAction(
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const record = await getGradeRecordById(id)
|
||||
|
||||
const parsed = GetGradeRecordByIdSchema.safeParse({ id })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid id",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const record = await getGradeRecordById(parsed.data.id)
|
||||
return { success: true, data: record }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
@@ -276,20 +353,29 @@ export async function exportGradesAction(params: {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = ExportGradesSchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid export parameters",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
let buffer: Buffer
|
||||
let filename: string
|
||||
|
||||
if (params.reportType === "class") {
|
||||
if (parsed.data.reportType === "class") {
|
||||
buffer = await exportClassGradeReportToExcel({
|
||||
classId: params.classId,
|
||||
classId: parsed.data.classId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
|
||||
} else {
|
||||
buffer = await exportGradeRecordsToExcel({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
examId: params.examId,
|
||||
classId: parsed.data.classId,
|
||||
subjectId: parsed.data.subjectId,
|
||||
examId: parsed.data.examId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Search, TrendingUp, Trophy, AlertCircle } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -18,11 +19,25 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import { batchCreateGradeRecordsAction } from "../actions"
|
||||
|
||||
type Option = { id: string; name: string }
|
||||
type Student = { id: string; name: string; email: string }
|
||||
type GradeType = "exam" | "quiz" | "homework" | "other"
|
||||
type Semester = "1" | "2"
|
||||
|
||||
function isGradeType(v: string): v is GradeType {
|
||||
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
|
||||
}
|
||||
|
||||
function isSemester(v: string): v is Semester {
|
||||
return v === "1" || v === "2"
|
||||
}
|
||||
|
||||
const MAX_SCORE = 100
|
||||
const DRAFT_KEY_PREFIX = "grade-draft"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
@@ -47,14 +62,155 @@ export function BatchGradeEntry({
|
||||
defaultSubjectId?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const initialDraftKey = `${DRAFT_KEY_PREFIX}-${defaultClassId ?? classes[0]?.id ?? ""}-${defaultSubjectId ?? subjects[0]?.id ?? ""}-exam`
|
||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
|
||||
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
|
||||
const [semester, setSemester] = useState<"1" | "2">("1")
|
||||
const [scores, setScores] = useState<Record<string, string>>({})
|
||||
const [type, setType] = useState<GradeType>("exam")
|
||||
const [semester, setSemester] = useState<Semester>("1")
|
||||
const [scores, setScores] = useState<Record<string, string>>(() => {
|
||||
// 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染)
|
||||
try {
|
||||
const raw = localStorage.getItem(initialDraftKey)
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
|
||||
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
|
||||
return data.scores
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const [draftRestored] = useState(() => {
|
||||
// 检查是否恢复了草稿(用于显示 toast)
|
||||
try {
|
||||
const raw = localStorage.getItem(initialDraftKey)
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
|
||||
return Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
return false
|
||||
})
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
|
||||
const handleScoreChange = (studentId: string, value: string) => {
|
||||
setScores((prev) => ({ ...prev, [studentId]: value }))
|
||||
const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}`
|
||||
|
||||
// 草稿恢复提示(仅在首次挂载时显示一次)
|
||||
useEffect(() => {
|
||||
if (draftRestored) {
|
||||
toast.info("已恢复未保存的成绩草稿")
|
||||
}
|
||||
}, [draftRestored])
|
||||
|
||||
const handleScoreChange = useCallback((studentId: string, value: string) => {
|
||||
if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) {
|
||||
setScores((prev) => ({ ...prev, [studentId]: value }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateScore = (value: string): boolean => {
|
||||
if (value === "") return true
|
||||
const num = Number(value)
|
||||
return !isNaN(num) && num >= 0 && num <= MAX_SCORE
|
||||
}
|
||||
|
||||
const restoreDraft = useCallback((key: string): boolean => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
|
||||
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
|
||||
setScores(data.scores)
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const handleClassChange = (newClassId: string) => {
|
||||
const hasUnsaved = Object.keys(scores).length > 0
|
||||
if (hasUnsaved && newClassId !== classId) {
|
||||
if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) {
|
||||
return
|
||||
}
|
||||
}
|
||||
setClassId(newClassId)
|
||||
setScores({})
|
||||
// 切换班级后尝试恢复该班级的草稿
|
||||
const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}`
|
||||
if (restoreDraft(newDraftKey)) {
|
||||
toast.info("已恢复未保存的成绩草稿")
|
||||
}
|
||||
const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry"
|
||||
router.push(newUrl)
|
||||
}
|
||||
|
||||
const filteredStudents = useMemo(
|
||||
() => students.filter((s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
[students, searchQuery]
|
||||
)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const validScores = students
|
||||
.map((s) => scores[s.id])
|
||||
.filter((v): v is string => v !== undefined && v !== "" && validateScore(v))
|
||||
.map(Number)
|
||||
const entered = validScores.length
|
||||
if (entered === 0) return { entered: 0, average: 0, max: 0, min: 0, total: students.length }
|
||||
const sum = validScores.reduce((acc, v) => acc + v, 0)
|
||||
return {
|
||||
entered,
|
||||
average: Math.round((sum / entered) * 100) / 100,
|
||||
max: Math.max(...validScores),
|
||||
min: Math.min(...validScores),
|
||||
total: students.length,
|
||||
}
|
||||
}, [scores, students])
|
||||
|
||||
const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v))
|
||||
|
||||
// 草稿保存到 localStorage(30秒间隔)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (Object.keys(scores).length > 0) {
|
||||
try {
|
||||
localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() }))
|
||||
} catch {
|
||||
// localStorage 可能已满或不可用,静默失败
|
||||
}
|
||||
}
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [scores, draftKey])
|
||||
|
||||
// 清除草稿
|
||||
const clearDraft = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(draftKey)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}, [draftKey])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, studentId: string) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const currentIndex = filteredStudents.findIndex((s) => s.id === studentId)
|
||||
const nextStudent = filteredStudents[currentIndex + 1]
|
||||
if (nextStudent && inputRefs.current[nextStudent.id]) {
|
||||
inputRefs.current[nextStudent.id]?.focus()
|
||||
inputRefs.current[nextStudent.id]?.select()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
@@ -63,19 +219,23 @@ export function BatchGradeEntry({
|
||||
return
|
||||
}
|
||||
|
||||
if (hasInvalidScores) {
|
||||
toast.error("存在无效分数(超过满分或格式错误),请检查后重试")
|
||||
return
|
||||
}
|
||||
|
||||
const records = students
|
||||
.map((s) => ({
|
||||
studentId: s.id,
|
||||
score: Number(scores[s.id] ?? 0),
|
||||
remark: undefined as string | undefined,
|
||||
}))
|
||||
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
|
||||
|
||||
if (records.length === 0) {
|
||||
toast.error("Please enter at least one score")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
formData.set("classId", classId)
|
||||
formData.set("subjectId", subjectId)
|
||||
formData.set("type", type)
|
||||
@@ -83,7 +243,9 @@ export function BatchGradeEntry({
|
||||
formData.set("recordsJson", JSON.stringify(records))
|
||||
|
||||
const result = await batchCreateGradeRecordsAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
clearDraft()
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/grades")
|
||||
router.refresh()
|
||||
@@ -93,16 +255,27 @@ export function BatchGradeEntry({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="relative">
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
Saving grades...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>Batch Grade Entry</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
满分 {MAX_SCORE} 分。输入分数后按 Enter 跳到下一位学生。草稿每 30 秒自动保存,2 小时内有效。
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Class</Label>
|
||||
<Select value={classId} onValueChange={setClassId}>
|
||||
<Select value={classId} onValueChange={handleClassChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
</SelectTrigger>
|
||||
@@ -139,12 +312,12 @@ export function BatchGradeEntry({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fullScore">Full Score</Label>
|
||||
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
|
||||
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue={String(MAX_SCORE)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
|
||||
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -159,7 +332,7 @@ export function BatchGradeEntry({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Semester</Label>
|
||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
||||
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -174,36 +347,94 @@ export function BatchGradeEntry({
|
||||
{students.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No students in this class.</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="w-32">Score</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{students.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={scores[s.id] ?? ""}
|
||||
onChange={(e) => handleScoreChange(s.id, e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</TableCell>
|
||||
<>
|
||||
{/* 实时统计栏 */}
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">已录入</span>
|
||||
<span className="font-semibold tabular-nums">{stats.entered}</span>
|
||||
<span className="text-muted-foreground">/ {stats.total}</span>
|
||||
</span>
|
||||
{stats.entered > 0 && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-muted-foreground">均分</span>
|
||||
<span className="font-semibold tabular-nums">{stats.average}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />
|
||||
<span className="text-muted-foreground">最高</span>
|
||||
<span className="font-semibold tabular-nums">{stats.max}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">最低</span>
|
||||
<span className="font-semibold tabular-nums">{stats.min}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasInvalidScores && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
存在无效分数
|
||||
</span>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search student..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-40 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||
<TableHead className="w-32">Score</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredStudents.map((s, idx) => {
|
||||
const scoreValue = scores[s.id] ?? ""
|
||||
const isInvalid = scoreValue !== "" && !validateScore(scoreValue)
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{s.email}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
ref={(el) => { inputRefs.current[s.id] = el }}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max={MAX_SCORE}
|
||||
placeholder="0"
|
||||
value={scoreValue}
|
||||
onChange={(e) => handleScoreChange(s.id, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, s.id)}
|
||||
className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")}
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
|
||||
@@ -18,6 +18,30 @@ interface GradeDistributionChartProps {
|
||||
data: GradeDistributionResult | null
|
||||
}
|
||||
|
||||
interface DistributionTooltipItem {
|
||||
label: string
|
||||
count: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface DistributionTooltipPayload {
|
||||
payload?: DistributionTooltipItem
|
||||
}
|
||||
|
||||
function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPayload {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
const obj = v as Record<string, unknown>
|
||||
const inner = obj.payload
|
||||
if (inner === undefined || inner === null) return true
|
||||
if (typeof inner !== "object") return false
|
||||
const item = inner as Record<string, unknown>
|
||||
return (
|
||||
typeof item.label === "string" &&
|
||||
typeof item.count === "number" &&
|
||||
typeof item.percentage === "number"
|
||||
)
|
||||
}
|
||||
|
||||
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
const isEmpty = !data || data.totalCount === 0
|
||||
|
||||
@@ -64,7 +88,8 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
const item = (payload as { payload?: { label: string; count: number; percentage: number } })?.payload
|
||||
if (!isDistributionTooltipPayload(payload)) return null
|
||||
const item = payload.payload
|
||||
if (!item) return null
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
|
||||
@@ -15,6 +15,16 @@ import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { createGradeRecordAction } from "../actions"
|
||||
|
||||
type Option = { id: string; name: string }
|
||||
type GradeType = "exam" | "quiz" | "homework" | "other"
|
||||
type Semester = "1" | "2"
|
||||
|
||||
function isGradeType(v: string): v is GradeType {
|
||||
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
|
||||
}
|
||||
|
||||
function isSemester(v: string): v is Semester {
|
||||
return v === "1" || v === "2"
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
@@ -42,8 +52,8 @@ export function GradeRecordForm({
|
||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
|
||||
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
|
||||
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
|
||||
const [semester, setSemester] = useState<"1" | "2">("1")
|
||||
const [type, setType] = useState<GradeType>("exam")
|
||||
const [semester, setSemester] = useState<Semester>("1")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId || !subjectId || !studentId) {
|
||||
@@ -139,7 +149,7 @@ export function GradeRecordForm({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
|
||||
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -154,7 +164,7 @@ export function GradeRecordForm({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Semester</Label>
|
||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
||||
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { buildScopeClassFilter, normalize, toNumber } from "./lib/grade-utils"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionBucket,
|
||||
@@ -21,32 +22,6 @@ import type {
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const normalize = (score: number, fullScore: number): number => {
|
||||
if (fullScore <= 0) return 0
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") return sql`1=0`
|
||||
if (scope.type === "class_members") return null
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0
|
||||
? inArray(gradeRecords.studentId, scope.childrenIds)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
export interface GradeTrendParams {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
|
||||
@@ -8,21 +8,12 @@ import { gradeRecords } from "@/shared/db/schema"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import { normalize, toNumber } from "./lib/grade-utils"
|
||||
import type {
|
||||
RankingTrendPoint,
|
||||
RankingTrendResult,
|
||||
} from "./types"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const normalize = (score: number, fullScore: number): number => {
|
||||
if (fullScore <= 0) return 0
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a student's ranking trend across assessments within their class.
|
||||
* Each point represents one assessment (grouped by title), with the
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, count, desc, eq, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
@@ -16,6 +16,7 @@ import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { buildScopeClassFilter, toNumber } from "./lib/grade-utils"
|
||||
import type {
|
||||
ClassGradeStats,
|
||||
ClassRankingItem,
|
||||
@@ -31,11 +32,6 @@ import type {
|
||||
UpdateGradeRecordInput,
|
||||
} from "./schema"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||
id: r.id,
|
||||
studentId: r.studentId,
|
||||
@@ -54,26 +50,6 @@ const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") {
|
||||
return sql`1=0`
|
||||
}
|
||||
if (scope.type === "class_members") {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0 ? inArray(gradeRecords.studentId, scope.childrenIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "owned") {
|
||||
return eq(gradeRecords.studentId, scope.userId)
|
||||
}
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
export const getGradeRecords = cache(
|
||||
async (
|
||||
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
||||
|
||||
51
src/modules/grades/lib/grade-utils.ts
Normal file
51
src/modules/grades/lib/grade-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
* Safely convert an unknown value to a finite number.
|
||||
* Returns 0 when the value is not a finite number.
|
||||
*
|
||||
* Used to normalize numeric columns returned by Drizzle (which may be
|
||||
* string | number depending on the driver) into plain numbers.
|
||||
*/
|
||||
export const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw score to a 0-100 scale based on its full score.
|
||||
* Returns 0 when fullScore is non-positive. Result is rounded to 2 decimals.
|
||||
*/
|
||||
export const normalize = (score: number, fullScore: number): number => {
|
||||
if (fullScore <= 0) return 0
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Drizzle SQL filter that restricts `gradeRecords` rows based on
|
||||
* the current user's DataScope. Returns `null` when no row-level filter
|
||||
* is required (e.g. admin / student viewing their own records — the caller
|
||||
* is expected to add the studentId condition separately for `class_members`).
|
||||
*/
|
||||
export const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0
|
||||
? inArray(gradeRecords.classId, scope.classIds)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") return sql`1=0`
|
||||
if (scope.type === "class_members") return null
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0
|
||||
? inArray(gradeRecords.studentId, scope.childrenIds)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
|
||||
return sql`1=0`
|
||||
}
|
||||
@@ -50,3 +50,74 @@ export const UpdateGradeRecordSchema = z.object({
|
||||
})
|
||||
|
||||
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>
|
||||
|
||||
// --- 查询/分析相关 Schema(P1-3 新增:为缺失 Zod 校验的 Action 补齐) ---
|
||||
|
||||
export const DeleteGradeRecordSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const GetGradeRecordByIdSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const GradeQuerySchema = z.object({
|
||||
classId: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
studentId: z.string().optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
examId: z.string().optional(),
|
||||
})
|
||||
|
||||
export type GradeQueryInput = z.infer<typeof GradeQuerySchema>
|
||||
|
||||
export const ClassGradeStatsQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const StudentGradeSummaryQuerySchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const ClassRankingQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ExportGradesSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
reportType: z.enum(["detail", "class"]).optional(),
|
||||
})
|
||||
|
||||
export type ExportGradesInput = z.infer<typeof ExportGradesSchema>
|
||||
|
||||
export const GradeTrendQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ClassComparisonQuerySchema = z.object({
|
||||
gradeId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const SubjectComparisonQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const GradeDistributionQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const RankingTrendQuerySchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user