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:
SpecialX
2026-06-22 16:23:34 +08:00
parent 20691f53ce
commit 45ee1ae43c
29 changed files with 2276 additions and 186 deletions

View File

@@ -0,0 +1,11 @@
import { redirect } from "next/navigation"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
export default async function GradeManagementPage(): Promise<void> {
await requirePermission(Permissions.GRADE_MANAGE)
redirect("/management/grade/classes")
}

View File

@@ -1,7 +1,8 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
@@ -13,7 +14,7 @@ export default async function ClassDiagnosticPage({
params: Promise<{ classId: string }>
}): Promise<JSX.Element> {
const { classId } = await params
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {

View File

@@ -1,5 +1,6 @@
import type { JSX } from "react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { ReportList } from "@/modules/diagnostic/components/report-list"
@@ -33,7 +34,7 @@ export default async function TeacherDiagnosticPage({
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
const reportType = getParam(sp, "reportType")
const status = getParam(sp, "status")

View File

@@ -1,7 +1,8 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
getStudentMasterySummary,
getKnowledgePointStats,
@@ -18,7 +19,7 @@ export default async function StudentDiagnosticPage({
params: Promise<{ studentId: string }>
}): Promise<JSX.Element> {
const { studentId } = await params
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
// DataScope 二次校验:学生只能看自己,家长只能看子女
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {

View File

@@ -4,7 +4,8 @@ import { BarChart3, ArrowLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGrades } from "@/modules/school/data-access"
@@ -30,7 +31,7 @@ export default async function GradeAnalyticsPage({
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")

View File

@@ -3,7 +3,9 @@ import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
@@ -26,13 +28,15 @@ function parseSemester(v?: string): GradeRecordSemester | undefined {
return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined
}
const PAGE_SIZE = 20
export default async function TeacherGradesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
@@ -55,24 +59,32 @@ export default async function TeacherGradesPage({
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
// 分页计算
const { page } = computePagination(sp, PAGE_SIZE)
const total = records.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.min(page, totalPages)
const pagedRecords = paginate(records, currentPage, PAGE_SIZE)
const hasFilters = Boolean(classId || subjectId || type || semester)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grades</h1>
<p className="text-muted-foreground">Manage student grade records.</p>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/grades/stats">
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
Statistics
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/teacher/grades/entry">
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
Batch Entry
</Link>
</Button>
<ExportButton
@@ -83,7 +95,7 @@ export default async function TeacherGradesPage({
<Button asChild>
<Link href="/teacher/grades/entry">
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
Record Grades
</Link>
</Button>
</div>
@@ -91,18 +103,31 @@ export default async function TeacherGradesPage({
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{records.length === 0 && !classId && !subjectId ? (
{records.length === 0 && !hasFilters ? (
<EmptyState
title="No grade records"
description="Start by recording grades for your classes."
title="暂无成绩记录"
description="开始为您的班级录入成绩。"
icon={ClipboardList}
action={{
label: "Record Grades",
label: "录入成绩",
href: "/teacher/grades/entry",
}}
/>
) : (
<GradeRecordList records={records} />
<div className="space-y-4">
<GradeRecordList records={pagedRecords} />
{total > 0 ? (
<ListPagination
page={currentPage}
pageSize={PAGE_SIZE}
total={total}
totalPages={totalPages}
basePath="/teacher/grades"
searchParams={sp}
itemLabel="条记录"
/>
) : null}
</div>
)}
</div>
)

View File

@@ -131,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
>
<span className="max-w-[120px] truncate text-xs font-medium">
<span className="max-w-32 truncate text-xs font-medium">
{kp.knowledgePointName}
</span>
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
@@ -252,7 +252,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-[180px]"
className="w-44"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>

View File

@@ -42,7 +42,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
domain={[0, 100]}
tickCount={5}
showLegend={hasClassAverage}
heightClassName="mx-auto h-[360px] w-full max-w-[520px]"
heightClassName="mx-auto h-96 w-full max-w-lg"
gridStrokeDasharray="4 4"
series={[
{

View File

@@ -195,7 +195,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<span className="text-sm font-medium">
{r.period ?? "Untitled period"}
</span>
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-xs">
{r.reportType}
</Badge>
</div>

View File

@@ -1,11 +1,12 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, inArray, type SQL } from "drizzle-orm"
import { and, desc, eq, type SQL } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db"
import { learningDiagnosticReports, users } from "@/shared/db/schema"
import { learningDiagnosticReports } from "@/shared/db/schema"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
import type {
@@ -132,31 +133,25 @@ export const getDiagnosticReports = cache(
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
const rows = await db
.select({
report: learningDiagnosticReports,
studentName: users.name,
})
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(learningDiagnosticReports.createdAt))
const generatorIds = Array.from(
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
)
const generatorMap = new Map<string, string>()
if (generatorIds.length > 0) {
const generators = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, generatorIds))
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>()
for (const r of rows) {
userIds.add(r.report.studentId)
if (r.report.generatedBy) userIds.add(r.report.generatedBy)
}
const userMap = await getUserNamesByIds(Array.from(userIds))
return rows.map((r) => ({
...serializeReport(r.report),
studentName: r.studentName ?? "Unknown",
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
studentName: userMap.get(r.report.studentId)?.name ?? "Unknown",
generatedByName: r.report.generatedBy
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null,
}))
},
)
@@ -165,26 +160,23 @@ export const getDiagnosticReports = cache(
export const getDiagnosticReportById = cache(
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
const [row] = await db
.select({ report: learningDiagnosticReports, studentName: users.name })
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(eq(learningDiagnosticReports.id, id))
.limit(1)
if (!row) return null
let generatedByName: string | null = null
if (row.report.generatedBy) {
const [gen] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, row.report.generatedBy))
.limit(1)
generatedByName = gen?.name ?? null
}
// 通过 users data-access 获取学生姓名和生成者姓名
const userIds = [row.report.studentId]
if (row.report.generatedBy) userIds.push(row.report.generatedBy)
const userMap = await getUserNamesByIds(userIds)
return {
...serializeReport(row.report),
studentName: row.studentName ?? "Unknown",
generatedByName,
studentName: userMap.get(row.report.studentId)?.name ?? "Unknown",
generatedByName: row.report.generatedBy
? userMap.get(row.report.generatedBy)?.name ?? null
: null,
}
},
)

View File

@@ -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) {

View File

@@ -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`

View File

@@ -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))
// 草稿保存到 localStorage30秒间隔
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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View 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`
}

View File

@@ -50,3 +50,74 @@ export const UpdateGradeRecordSchema = z.object({
})
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>
// --- 查询/分析相关 SchemaP1-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(),
})

View File

@@ -0,0 +1,87 @@
{
"title": {
"student": "Student Diagnostic",
"class": "Class Diagnostic",
"reportList": "Diagnostic Reports",
"myDiagnostic": "My Diagnostic"
},
"type": {
"individual": "Individual",
"class": "Class",
"grade": "Grade"
},
"status": {
"draft": "Draft",
"published": "Published",
"archived": "Archived"
},
"filters": {
"reportType": "Report Type",
"status": "Status",
"allTypes": "All types",
"allStatuses": "All statuses"
},
"summary": {
"overallMastery": "Overall Mastery",
"strengths": "Strengths",
"weaknesses": "Weaknesses",
"students": "Students",
"avgMastery": "Avg Mastery",
"needAttention": "Need Attention",
"class": "Class",
"student": "Student"
},
"chart": {
"radarTitle": "Knowledge Point Mastery",
"radarDescription": "Radar chart of mastery level (student vs class average)",
"heatmapTitle": "Knowledge Point Mastery Heatmap",
"rankingTitle": "Knowledge Point Ranking",
"noMasteryData": "No knowledge point mastery records found."
},
"report": {
"generate": "Generate Diagnostic Report",
"generateStudent": "Generate Student Diagnostic Report",
"generateClass": "Generate Class Diagnostic Report",
"publish": "Publish",
"delete": "Delete",
"publishTitle": "Publish Report",
"publishConfirmation": "Are you sure you want to publish this report? It will be visible to relevant users.",
"deleteTitle": "Delete Report",
"deleteConfirmation": "Are you sure you want to delete this report? This action cannot be undone.",
"confirm": "Confirm",
"cancel": "Cancel",
"publishing": "Publishing...",
"deleting": "Deleting...",
"recommendations": "Recommendations",
"history": "Report History",
"period": "Period",
"createdAt": "Created At",
"generatedBy": "Generated By",
"overallScore": "Overall Score",
"actions": "Actions"
},
"strengths": {
"title": "Strengths (≥80%)",
"practice": "Practice",
"empty": "No strength knowledge points"
},
"weaknesses": {
"title": "Weaknesses (<60%)",
"practice": "Practice",
"empty": "No weakness knowledge points"
},
"empty": {
"noData": "No diagnostic data",
"noClassData": "Unable to load class mastery summary.",
"noMastery": "No knowledge point mastery records found.",
"noReports": "No diagnostic reports"
},
"error": {
"generateFailed": "Failed to generate report",
"generateClassFailed": "Failed to generate class report",
"publishFailed": "Failed to publish",
"deleteFailed": "Failed to delete",
"loadFailed": "Failed to load",
"retry": "Retry"
}
}

View File

@@ -0,0 +1,155 @@
{
"delete": {
"title": "Delete Grade",
"description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
},
"empty": {
"noGrades": {
"title": "No Grades",
"description": "No grades have been created for this school yet. Click \"Create Grade\" to start."
},
"noMatch": {
"title": "No Matches",
"description": "Try adjusting your search or filters."
},
"noAssignedGrades": {
"title": "No Assigned Grades",
"description": "You have not been assigned to manage any grades."
},
"selectGrade": {
"title": "Select a Grade",
"description": "Select a grade to view insights."
},
"noInsights": {
"title": "No Insights Available",
"description": "There is not enough assignment or grade data to generate insights for this grade."
}
},
"error": {
"loadFailed": "Failed to load",
"deleteFailed": "Failed to delete",
"noPermission": {
"title": "No Permission",
"description": "You do not have permission to perform this action."
}
},
"form": {
"create": {
"title": "Create Grade"
},
"edit": {
"title": "Edit Grade"
},
"field": {
"school": "School",
"name": "Grade Name",
"order": "Order",
"gradeHead": "Grade Head",
"teachingHead": "Teaching Head"
},
"placeholder": {
"school": "Select a school",
"name": "e.g. Grade 1",
"gradeHead": "Select grade head",
"teachingHead": "Select teaching head"
},
"button": {
"cancel": "Cancel",
"create": "Create",
"save": "Save"
},
"errors": {
"schoolRequired": "Please select a school",
"nameRequired": "Please enter a grade name",
"nameTooLong": "Grade name cannot exceed 50 characters",
"nameDuplicate": "A grade with this name already exists in this school",
"orderInvalid": "Order must be an integer"
}
},
"insights": {
"assignments": {
"title": "Assignments",
"column": {
"assignment": "Assignment",
"status": "Status",
"createdAt": "Created At",
"targeted": "Targeted",
"submitted": "Submitted",
"graded": "Graded",
"avg": "Average",
"median": "Median"
}
},
"ranking": {
"title": "Class Ranking",
"column": {
"class": "Class",
"students": "Students",
"latestAvg": "Latest Avg",
"prevAvg": "Previous Avg",
"delta": "Delta",
"overallAvg": "Overall Avg"
}
},
"stats": {
"classCount": "Classes",
"studentCount": "Students",
"studentDetail": "{count} students in total",
"overallAvg": "Overall Average",
"overallAvgDesc": "Weighted average across all grades",
"latestAvg": "Latest Average"
}
},
"list": {
"title": "Grade List",
"notSet": "Not Set",
"column": {
"school": "School",
"grade": "Grade",
"order": "Order",
"gradeHead": "Grade Head",
"teachingHead": "Teaching Head",
"updatedAt": "Updated At",
"actions": "Actions"
},
"actions": {
"insights": "Insights",
"edit": "Edit",
"delete": "Delete"
}
},
"page": {
"insights": {
"title": "Grade Insights"
}
},
"toast": {
"deleteSuccess": "Deleted successfully",
"createSuccess": "Created successfully",
"updateSuccess": "Updated successfully"
},
"toolbar": {
"search": "Search grades...",
"reset": "Reset",
"create": "Create Grade",
"filter": {
"school": "School",
"allSchools": "All Schools",
"head": "Head",
"allHeads": "All Heads",
"missingBoth": "Missing Both Heads",
"missingGradeHead": "Missing Grade Head",
"missingTeachingHead": "Missing Teaching Head",
"sort": "Sort",
"sortDefault": "Default",
"sortUpdatedDesc": "Recently Updated",
"sortUpdatedAsc": "Oldest Updated",
"sortNameAsc": "Name Ascending",
"sortNameDesc": "Name Descending",
"sortOrderAsc": "Order Ascending",
"sortOrderDesc": "Order Descending"
}
}
}

View File

@@ -0,0 +1,143 @@
{
"title": {
"list": "Grade Records",
"entry": "Grade Entry",
"analytics": "Grade Analytics",
"stats": "Grade Statistics",
"myGrades": "My Grades",
"childrenGrades": "Children Grades"
},
"filters": {
"class": "Class",
"subject": "Subject",
"type": "Type",
"semester": "Semester",
"allClasses": "All classes",
"allSubjects": "All subjects",
"allTypes": "All types",
"allSemesters": "All semesters",
"searchPlaceholder": "Search by title..."
},
"type": {
"exam": "Exam",
"quiz": "Quiz",
"homework": "Homework",
"other": "Other"
},
"semester": {
"s1": "Semester 1",
"s2": "Semester 2"
},
"list": {
"empty": "No grade records found.",
"columns": {
"student": "Student",
"class": "Class",
"subject": "Subject",
"title": "Title",
"score": "Score",
"type": "Type",
"semester": "Semester",
"recordedBy": "Recorded By",
"date": "Date"
}
},
"form": {
"title": "Record Grade",
"save": "Save Record",
"saving": "Saving...",
"cancel": "Cancel",
"selectClass": "Select a class",
"selectSubject": "Select a subject",
"selectStudent": "Select a student",
"titlePlaceholder": "e.g. Mid-term Exam",
"score": "Score",
"fullScore": "Full Score",
"remark": "Remark (optional)",
"remarkPlaceholder": "Notes about this grade...",
"selectPrompt": "Please select class, subject and student"
},
"delete": {
"title": "Delete Grade Record",
"confirmation": "Are you sure you want to delete this grade record? This action cannot be undone.",
"confirm": "Delete",
"cancel": "Cancel",
"deleting": "Deleting..."
},
"export": {
"detail": "Export Grade Details",
"classReport": "Export Class Grade Report",
"success": "Export succeeded",
"failed": "Export failed"
},
"stats": {
"title": "Statistics",
"average": "Average",
"median": "Median",
"max": "Max",
"min": "Min",
"stdDev": "Std Dev",
"variance": "Variance",
"passRate": "Pass Rate",
"excellentRate": "Excellent Rate",
"count": "Count"
},
"analytics": {
"trend": "Grade Trend",
"classComparison": "Class Comparison",
"subjectComparison": "Subject Comparison",
"distribution": "Grade Distribution",
"ranking": "Ranking",
"rankingTrend": "Ranking Trend",
"class": "Class",
"subject": "Subject",
"grade": "Grade",
"averageScore": "Average score, pass rate, excellent rate",
"passRate": "Pass Rate",
"excellentRate": "Excellent Rate",
"studentCount": "Student Count"
},
"batch": {
"title": "Batch Grade Entry",
"saving": "Saving...",
"restored": "Restored unsaved grade draft",
"invalidScores": "Invalid scores found",
"fullScoreRequired": "Full score is required",
"saved": "Saved",
"score": "Score",
"remark": "Remark",
"fullScore": "Full Score",
"type": "Type",
"saveAll": "Save All",
"cancel": "Cancel"
},
"trend": {
"title": "Grade Trend",
"empty": "No grade records yet",
"score": "Score",
"date": "Date"
},
"summary": {
"title": "Grade Summary",
"averageScore": "Average Score",
"classRank": "Class Rank",
"totalRecords": "Total Records",
"highestScore": "Highest Score",
"lowestScore": "Lowest Score"
},
"empty": {
"noRecords": "No grade records found.",
"noData": "No data available",
"noClassSelected": "Please select a class",
"noStudentSelected": "Please select a student"
},
"error": {
"loadFailed": "Failed to load",
"saveFailed": "Failed to save",
"deleteFailed": "Failed to delete",
"exportFailed": "Failed to export",
"failedToCreate": "Failed to create",
"failedToDelete": "Failed to delete",
"retry": "Retry"
}
}

View File

@@ -0,0 +1,87 @@
{
"title": {
"student": "学生学情诊断",
"class": "班级学情诊断",
"reportList": "诊断报告",
"myDiagnostic": "我的学情诊断"
},
"type": {
"individual": "个人",
"class": "班级",
"grade": "年级"
},
"status": {
"draft": "草稿",
"published": "已发布",
"archived": "已归档"
},
"filters": {
"reportType": "报告类型",
"status": "状态",
"allTypes": "全部类型",
"allStatuses": "全部状态"
},
"summary": {
"overallMastery": "总体掌握度",
"strengths": "强项",
"weaknesses": "弱项",
"students": "学生数",
"avgMastery": "平均掌握度",
"needAttention": "需重点关注",
"class": "班级",
"student": "学生"
},
"chart": {
"radarTitle": "知识点掌握度",
"radarDescription": "掌握度雷达图(学生 vs 班级平均)",
"heatmapTitle": "知识点掌握度热力图",
"rankingTitle": "知识点排名",
"noMasteryData": "暂无知识点掌握度记录"
},
"report": {
"generate": "生成诊断报告",
"generateStudent": "生成学生诊断报告",
"generateClass": "生成班级诊断报告",
"publish": "发布",
"delete": "删除",
"publishTitle": "发布报告",
"publishConfirmation": "确定要发布此报告吗?发布后将对相关人员可见。",
"deleteTitle": "删除报告",
"deleteConfirmation": "确定要删除此报告吗?此操作不可撤销。",
"confirm": "确认",
"cancel": "取消",
"publishing": "发布中...",
"deleting": "删除中...",
"recommendations": "学习建议",
"history": "报告历史",
"period": "周期",
"createdAt": "创建时间",
"generatedBy": "生成者",
"overallScore": "总体得分",
"actions": "操作"
},
"strengths": {
"title": "强项≥80%",
"practice": "练习",
"empty": "暂无强项知识点"
},
"weaknesses": {
"title": "弱项(<60%",
"practice": "练习",
"empty": "暂无弱项知识点"
},
"empty": {
"noData": "暂无诊断数据",
"noClassData": "无法加载班级掌握度摘要",
"noMastery": "暂无知识点掌握度记录",
"noReports": "暂无诊断报告"
},
"error": {
"generateFailed": "生成报告失败",
"generateClassFailed": "生成班级报告失败",
"publishFailed": "发布失败",
"deleteFailed": "删除失败",
"loadFailed": "加载失败",
"retry": "重试"
}
}

View File

@@ -0,0 +1,155 @@
{
"delete": {
"title": "删除年级",
"description": "确定要删除「{name}」吗?此操作不可撤销。",
"cancel": "取消",
"confirm": "删除"
},
"empty": {
"noGrades": {
"title": "暂无年级",
"description": "学校尚未创建任何年级,点击「新建年级」开始配置。"
},
"noMatch": {
"title": "无匹配结果",
"description": "尝试调整搜索条件或筛选器。"
},
"noAssignedGrades": {
"title": "未分配年级",
"description": "您尚未被分配管理任何年级。"
},
"selectGrade": {
"title": "请选择年级",
"description": "选择一个年级以查看洞察数据。"
},
"noInsights": {
"title": "暂无洞察数据",
"description": "所选年级暂无足够的作业或成绩数据生成洞察。"
}
},
"error": {
"loadFailed": "加载失败",
"deleteFailed": "删除失败",
"noPermission": {
"title": "无权限",
"description": "您没有权限执行此操作。"
}
},
"form": {
"create": {
"title": "新建年级"
},
"edit": {
"title": "编辑年级"
},
"field": {
"school": "学校",
"name": "年级名称",
"order": "排序",
"gradeHead": "年级主任",
"teachingHead": "教学主任"
},
"placeholder": {
"school": "选择学校",
"name": "如:一年级",
"gradeHead": "选择年级主任",
"teachingHead": "选择教学主任"
},
"button": {
"cancel": "取消",
"create": "创建",
"save": "保存"
},
"errors": {
"schoolRequired": "请选择学校",
"nameRequired": "请输入年级名称",
"nameTooLong": "年级名称不能超过 50 个字符",
"nameDuplicate": "该学校下已存在同名年级",
"orderInvalid": "排序必须为整数"
}
},
"insights": {
"assignments": {
"title": "作业列表",
"column": {
"assignment": "作业名称",
"status": "状态",
"createdAt": "创建时间",
"targeted": "目标人数",
"submitted": "已提交",
"graded": "已批改",
"avg": "平均分",
"median": "中位数"
}
},
"ranking": {
"title": "班级排名",
"column": {
"class": "班级",
"students": "学生数",
"latestAvg": "最新平均分",
"prevAvg": "上次平均分",
"delta": "变化",
"overallAvg": "总体平均分"
}
},
"stats": {
"classCount": "班级数",
"studentCount": "学生数",
"studentDetail": "共 {count} 名学生",
"overallAvg": "总体平均分",
"overallAvgDesc": "年级所有成绩的加权平均",
"latestAvg": "最新平均分"
}
},
"list": {
"title": "年级列表",
"notSet": "未设置",
"column": {
"school": "学校",
"grade": "年级",
"order": "排序",
"gradeHead": "年级主任",
"teachingHead": "教学主任",
"updatedAt": "更新时间",
"actions": "操作"
},
"actions": {
"insights": "洞察",
"edit": "编辑",
"delete": "删除"
}
},
"page": {
"insights": {
"title": "年级洞察"
}
},
"toast": {
"deleteSuccess": "删除成功",
"createSuccess": "创建成功",
"updateSuccess": "更新成功"
},
"toolbar": {
"search": "搜索年级...",
"reset": "重置",
"create": "新建年级",
"filter": {
"school": "学校",
"allSchools": "全部学校",
"head": "主任",
"allHeads": "全部主任",
"missingBoth": "缺少双主任",
"missingGradeHead": "缺少年级主任",
"missingTeachingHead": "缺少教学主任",
"sort": "排序",
"sortDefault": "默认排序",
"sortUpdatedDesc": "最近更新",
"sortUpdatedAsc": "最早更新",
"sortNameAsc": "名称升序",
"sortNameDesc": "名称降序",
"sortOrderAsc": "排序升序",
"sortOrderDesc": "排序降序"
}
}
}

View File

@@ -0,0 +1,143 @@
{
"title": {
"list": "成绩查询",
"entry": "成绩录入",
"analytics": "成绩分析",
"stats": "成绩统计",
"myGrades": "我的成绩",
"childrenGrades": "子女成绩"
},
"filters": {
"class": "班级",
"subject": "科目",
"type": "类型",
"semester": "学期",
"allClasses": "全部班级",
"allSubjects": "全部科目",
"allTypes": "全部类型",
"allSemesters": "全部学期",
"searchPlaceholder": "按标题搜索..."
},
"type": {
"exam": "考试",
"quiz": "测验",
"homework": "作业",
"other": "其他"
},
"semester": {
"s1": "第一学期",
"s2": "第二学期"
},
"list": {
"empty": "暂无成绩记录",
"columns": {
"student": "学生",
"class": "班级",
"subject": "科目",
"title": "标题",
"score": "分数",
"type": "类型",
"semester": "学期",
"recordedBy": "录入人",
"date": "日期"
}
},
"form": {
"title": "录入成绩",
"save": "保存",
"saving": "保存中...",
"cancel": "取消",
"selectClass": "选择班级",
"selectSubject": "选择科目",
"selectStudent": "选择学生",
"titlePlaceholder": "如期中考试",
"score": "分数",
"fullScore": "满分",
"remark": "备注(可选)",
"remarkPlaceholder": "关于此成绩的备注...",
"selectPrompt": "请选择班级、科目和学生"
},
"delete": {
"title": "删除成绩记录",
"confirmation": "确定要删除此成绩记录吗?此操作不可撤销。",
"confirm": "删除",
"cancel": "取消",
"deleting": "删除中..."
},
"export": {
"detail": "导出成绩明细",
"classReport": "导出班级成绩总表",
"success": "导出成功",
"failed": "导出失败"
},
"stats": {
"title": "统计",
"average": "平均分",
"median": "中位数",
"max": "最高分",
"min": "最低分",
"stdDev": "标准差",
"variance": "方差",
"passRate": "及格率",
"excellentRate": "优秀率",
"count": "人数"
},
"analytics": {
"trend": "成绩趋势",
"classComparison": "班级对比",
"subjectComparison": "科目对比",
"distribution": "分数分布",
"ranking": "排名",
"rankingTrend": "排名趋势",
"class": "班级",
"subject": "科目",
"grade": "年级",
"averageScore": "平均分",
"passRate": "及格率",
"excellentRate": "优秀率",
"studentCount": "学生数"
},
"batch": {
"title": "批量录入",
"saving": "保存中...",
"restored": "已恢复未保存的成绩草稿",
"invalidScores": "存在无效分数",
"fullScoreRequired": "满分必填",
"saved": "已录入",
"score": "分数",
"remark": "备注",
"fullScore": "满分",
"type": "类型",
"saveAll": "全部保存",
"cancel": "取消"
},
"trend": {
"title": "成绩趋势",
"empty": "暂无成绩记录",
"score": "分数",
"date": "日期"
},
"summary": {
"title": "成绩摘要",
"averageScore": "平均分",
"classRank": "班级排名",
"totalRecords": "总记录数",
"highestScore": "最高分",
"lowestScore": "最低分"
},
"empty": {
"noRecords": "暂无成绩记录",
"noData": "暂无数据",
"noClassSelected": "请选择班级",
"noStudentSelected": "请选择学生"
},
"error": {
"loadFailed": "加载失败",
"saveFailed": "保存失败",
"deleteFailed": "删除失败",
"exportFailed": "导出失败",
"failedToCreate": "创建失败",
"failedToDelete": "删除失败",
"retry": "重试"
}
}