feat(grades): add ranking trend, school-wide summary, score cell, and scope filter
- Add ranking-trend-card and school-wide-summary-card for broader analytics - Add score-cell and grade-filters components for table rendering - Add scope-filter and type-guards lib utilities for grade data filtering - Update actions, data-access (analytics, ranking, main), stats-service, export - Update schema, types, and grade-utils lib - Update all grade chart and report components (distribution, trend, comparison, query)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use server"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
|
||||
import {
|
||||
getClassComparison,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
RankingTrendQuerySchema,
|
||||
SubjectComparisonQuerySchema,
|
||||
} from "./schema"
|
||||
import { assertClassInScope } from "./actions"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionResult,
|
||||
@@ -45,6 +47,10 @@ export async function getGradeTrendAction(
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const result = await getGradeTrend({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
@@ -52,11 +58,7 @@ export async function getGradeTrendAction(
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,17 +77,17 @@ export async function getClassComparisonAction(
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:对 class_taught scope 校验(gradeId 维度无法直接校验,依赖 data-access 层过滤)
|
||||
// 这里不调用 assertClassInScope,因为没有 classId 参数。
|
||||
// data-access 层的 getClassComparison 已通过 buildScopeClassFilter 应用 scope 过滤。
|
||||
|
||||
const result = await getClassComparison({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,17 +106,17 @@ export async function getSubjectComparisonAction(
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const result = await getSubjectComparison({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +135,10 @@ export async function getGradeDistributionAction(
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const result = await getGradeDistribution({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
@@ -140,11 +146,7 @@ export async function getGradeDistributionAction(
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,17 +179,15 @@ export async function getRankingTrendAction(
|
||||
return { success: false, message: "Can only view your children's ranking trend" }
|
||||
}
|
||||
|
||||
// P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
|
||||
const result = await getRankingTrend(
|
||||
parsed.data.studentId,
|
||||
parsed.data.subjectId,
|
||||
parsed.data.semester
|
||||
parsed.data.semester,
|
||||
ctx.dataScope
|
||||
)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||
import { createNotification } from "@/modules/notifications/data-access"
|
||||
import { getSubjectNameById } from "@/modules/school/data-access"
|
||||
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
|
||||
|
||||
import {
|
||||
CreateGradeRecordSchema,
|
||||
@@ -20,20 +24,126 @@ import {
|
||||
import {
|
||||
createGradeRecord,
|
||||
batchCreateGradeRecords,
|
||||
undoBatchCreateGradeRecords,
|
||||
updateGradeRecord,
|
||||
deleteGradeRecord,
|
||||
bulkDeleteGradeRecords,
|
||||
getGradeRecords,
|
||||
getGradeRecordById,
|
||||
getClassGradeStatsWithMeta,
|
||||
getStudentGradeSummary,
|
||||
getClassRanking,
|
||||
saveGradeDraft,
|
||||
getGradeDraft,
|
||||
deleteGradeDraft,
|
||||
type GradeDraftData,
|
||||
} from "./data-access"
|
||||
import type { PaginatedGradeRecords } from "./data-access"
|
||||
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
|
||||
import {
|
||||
exportGradeRecordsToExcel,
|
||||
exportClassGradeReportToExcel,
|
||||
exportStudentGradeRecordsToExcel,
|
||||
} from "./export"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import type { GradeQueryParams, GradeRecordListItem, GradeStats } from "./types"
|
||||
import type { GradeQueryParams, GradeStats } from "./types"
|
||||
import type {
|
||||
StudentGradeSummary,
|
||||
ClassRankingItem,
|
||||
GradeRecord,
|
||||
} from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
* Validate that the given classId is within the user's DataScope.
|
||||
* Returns an error message string when access is denied, otherwise null.
|
||||
*
|
||||
* - `all` / `children` / `grade_managed`: allowed (children/grade_managed are
|
||||
* validated at the data layer via row-level filters; classId scoping would
|
||||
* require extra cross-module lookups and is intentionally permissive here).
|
||||
* - `class_taught` / `class_members`: classId must be in scope.classIds.
|
||||
* - `owned`: never allowed (owned scope has no class context).
|
||||
*/
|
||||
export function assertClassInScope(
|
||||
scope: DataScope,
|
||||
classId: string
|
||||
): string | null {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "children") return null
|
||||
if (scope.type === "grade_managed") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.includes(classId)
|
||||
? null
|
||||
: "You can only access classes you teach"
|
||||
}
|
||||
if (scope.type === "class_members") {
|
||||
return scope.classIds.includes(classId)
|
||||
? null
|
||||
: "You can only access your own class"
|
||||
}
|
||||
return "Access denied for your scope"
|
||||
}
|
||||
|
||||
/**
|
||||
* v4-P1-6: 成绩录入后通知学生和家长。
|
||||
* 通知失败不阻断成绩录入主流程。
|
||||
*/
|
||||
async function notifyGradeEntered(params: {
|
||||
studentIds: string[]
|
||||
title: string
|
||||
subjectId?: string
|
||||
score?: number
|
||||
fullScore?: number
|
||||
}): Promise<void> {
|
||||
if (params.studentIds.length === 0) return
|
||||
|
||||
const subjectName = params.subjectId
|
||||
? (await getSubjectNameById(params.subjectId)) ?? "未知科目"
|
||||
: "未知科目"
|
||||
const scoreText =
|
||||
params.score !== undefined && params.fullScore !== undefined
|
||||
? `${params.score}/${params.fullScore}`
|
||||
: "已录入"
|
||||
|
||||
const title = `成绩已录入:${params.title}`
|
||||
const content = `您的${subjectName}成绩${scoreText},请查看详情。`
|
||||
|
||||
// 通知学生本人
|
||||
for (const studentId of params.studentIds) {
|
||||
try {
|
||||
await createNotification({
|
||||
userId: studentId,
|
||||
type: "grade",
|
||||
title,
|
||||
content,
|
||||
link: "/student/grades",
|
||||
})
|
||||
} catch {
|
||||
// 单条通知失败忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 通知家长
|
||||
try {
|
||||
const parentIds = await getParentIdsByStudentIds(params.studentIds)
|
||||
const parentContent = `您的孩子的${subjectName}成绩${scoreText},请查看详情。`
|
||||
for (const parentId of parentIds) {
|
||||
try {
|
||||
await createNotification({
|
||||
userId: parentId,
|
||||
type: "grade",
|
||||
title,
|
||||
content: parentContent,
|
||||
link: "/parent/grades",
|
||||
})
|
||||
} catch {
|
||||
// 单条通知失败忽略
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 家长查询失败忽略
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGradeRecordAction(
|
||||
prevState: ActionState<string> | null,
|
||||
@@ -65,21 +175,42 @@ export async function createGradeRecordAction(
|
||||
}
|
||||
|
||||
const id = await createGradeRecord(parsed.data, ctx.userId)
|
||||
// v3-P1-5:成绩录入后更新诊断掌握度
|
||||
if (parsed.data.examId) {
|
||||
try {
|
||||
await updateMasteryFromExamScore(
|
||||
parsed.data.studentId,
|
||||
parsed.data.examId,
|
||||
parsed.data.score,
|
||||
parsed.data.fullScore ?? 100,
|
||||
)
|
||||
} catch {
|
||||
// 掌握度更新失败不应阻断成绩录入
|
||||
}
|
||||
}
|
||||
// v4-P1-6:成绩录入后通知学生和家长
|
||||
try {
|
||||
await notifyGradeEntered({
|
||||
studentIds: [parsed.data.studentId],
|
||||
title: parsed.data.title,
|
||||
subjectId: parsed.data.subjectId,
|
||||
score: parsed.data.score,
|
||||
fullScore: parsed.data.fullScore ?? 100,
|
||||
})
|
||||
} catch {
|
||||
// 通知失败不阻断成绩录入
|
||||
}
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record created", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function batchCreateGradeRecordsAction(
|
||||
prevState: ActionState<number> | null,
|
||||
prevState: ActionState<string[]> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<number>> {
|
||||
): Promise<ActionState<string[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
@@ -88,6 +219,9 @@ export async function batchCreateGradeRecordsAction(
|
||||
return { success: false, message: "Missing records data" }
|
||||
}
|
||||
|
||||
// P3 修复:使用 safeJsonParse 替代 JSON.parse,避免 SyntaxError 暴露
|
||||
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
|
||||
|
||||
const parsed = BatchCreateGradeRecordSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
subjectId: formData.get("subjectId"),
|
||||
@@ -97,7 +231,7 @@ export async function batchCreateGradeRecordsAction(
|
||||
fullScore: formData.get("fullScore") || undefined,
|
||||
type: formData.get("type") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
records: JSON.parse(recordsJson),
|
||||
records,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -108,15 +242,74 @@ export async function batchCreateGradeRecordsAction(
|
||||
}
|
||||
}
|
||||
|
||||
const count = await batchCreateGradeRecords(parsed.data, ctx.userId)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: `Created ${count} grade records`, data: count }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
// v3-P2-3:返回创建的记录 ID 列表,供前端撤销使用
|
||||
const ids = await batchCreateGradeRecords(parsed.data, ctx.userId)
|
||||
// v3-P1-5:批量成绩录入后更新诊断掌握度
|
||||
if (parsed.data.examId) {
|
||||
const fullScore = parsed.data.fullScore ?? 100
|
||||
for (const record of parsed.data.records) {
|
||||
try {
|
||||
await updateMasteryFromExamScore(
|
||||
record.studentId,
|
||||
parsed.data.examId,
|
||||
record.score,
|
||||
fullScore,
|
||||
)
|
||||
} catch {
|
||||
// 掌握度更新失败不应阻断成绩录入
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
// v4-P1-6:批量成绩录入后通知学生和家长
|
||||
try {
|
||||
await notifyGradeEntered({
|
||||
studentIds: parsed.data.records.map((r) => r.studentId),
|
||||
title: parsed.data.title,
|
||||
subjectId: parsed.data.subjectId,
|
||||
fullScore: parsed.data.fullScore ?? 100,
|
||||
})
|
||||
} catch {
|
||||
// 通知失败不阻断成绩录入
|
||||
}
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `Created ${ids.length} grade records`,
|
||||
data: ids,
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-3: 撤销最近一次批量成绩录入。
|
||||
* 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。
|
||||
*/
|
||||
export async function undoBatchCreateGradeRecordsAction(
|
||||
ids: string[]
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return { success: false, message: "No records to undo" }
|
||||
}
|
||||
|
||||
// 防御性:限制单次撤销的记录数量,避免误传超大数组
|
||||
if (ids.length > 500) {
|
||||
return { success: false, message: "Cannot undo more than 500 records at once" }
|
||||
}
|
||||
|
||||
const deletedCount = await undoBatchCreateGradeRecords(ids, ctx.userId)
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `Undone ${deletedCount} grade records`,
|
||||
data: deletedCount,
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +343,7 @@ export async function updateGradeRecordAction(
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record updated" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,17 +366,44 @@ export async function deleteGradeRecordAction(
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record deleted" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P3-2: 批量删除成绩记录。
|
||||
* 使用 inArray 一次性删除,避免 N+1 查询。
|
||||
* 限制单次最多删除 500 条记录。
|
||||
*/
|
||||
export async function bulkDeleteGradeRecordsAction(
|
||||
ids: string[]
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return { success: false, message: "No records to delete" }
|
||||
}
|
||||
// 防御性:限制单次删除的记录数量
|
||||
if (ids.length > 500) {
|
||||
return { success: false, message: "Cannot delete more than 500 records at once" }
|
||||
}
|
||||
|
||||
const deletedCount = await bulkDeleteGradeRecords(ids)
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `Deleted ${deletedCount} grade records`,
|
||||
data: deletedCount,
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradeRecordsAction(
|
||||
params: GradeQueryParams
|
||||
): Promise<ActionState<GradeRecordListItem[]>> {
|
||||
params: GradeQueryParams & { limit?: number; offset?: number }
|
||||
): Promise<ActionState<PaginatedGradeRecords>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
@@ -200,18 +416,22 @@ export async function getGradeRecordsAction(
|
||||
}
|
||||
}
|
||||
|
||||
const records = await getGradeRecords({
|
||||
// P3 修复:对 class_taught scope 校验 classId
|
||||
if (parsed.data.classId) {
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
}
|
||||
|
||||
const result = await getGradeRecords({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
})
|
||||
return { success: true, data: records }
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +441,7 @@ export async function getClassGradeStatsAction(
|
||||
examId?: string
|
||||
): Promise<ActionState<GradeStats | null>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = ClassGradeStatsQuerySchema.safeParse({ classId, subjectId, examId })
|
||||
if (!parsed.success) {
|
||||
@@ -232,24 +452,25 @@ export async function getClassGradeStatsAction(
|
||||
}
|
||||
}
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const result = await getClassGradeStatsWithMeta(
|
||||
parsed.data.classId,
|
||||
parsed.data.subjectId,
|
||||
parsed.data.examId
|
||||
parsed.data.examId,
|
||||
ctx.dataScope,
|
||||
ctx.userId
|
||||
)
|
||||
return { success: true, data: result?.stats ?? null }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentGradeSummaryAction(
|
||||
studentId: string
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getStudentGradeSummary>>>> {
|
||||
): Promise<ActionState<StudentGradeSummary | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
@@ -272,14 +493,11 @@ export async function getStudentGradeSummaryAction(
|
||||
return { success: false, message: "Can only view your children's grades" }
|
||||
}
|
||||
|
||||
const summary = await getStudentGradeSummary(parsed.data.studentId)
|
||||
// P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
|
||||
const summary = await getStudentGradeSummary(parsed.data.studentId, ctx.dataScope)
|
||||
return { success: true, data: summary }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +505,9 @@ export async function getClassRankingAction(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
|
||||
): Promise<ActionState<ClassRankingItem[]>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = ClassRankingQuerySchema.safeParse({ classId, subjectId, examId })
|
||||
if (!parsed.success) {
|
||||
@@ -300,26 +518,27 @@ export async function getClassRankingAction(
|
||||
}
|
||||
}
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const ranking = await getClassRanking(
|
||||
parsed.data.classId,
|
||||
parsed.data.subjectId,
|
||||
parsed.data.examId
|
||||
parsed.data.examId,
|
||||
ctx.dataScope,
|
||||
ctx.userId
|
||||
)
|
||||
return { success: true, data: ranking }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradeRecordByIdAction(
|
||||
id: string
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
|
||||
): Promise<ActionState<GradeRecord | null>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const parsed = GetGradeRecordByIdSchema.safeParse({ id })
|
||||
if (!parsed.success) {
|
||||
@@ -331,13 +550,30 @@ export async function getGradeRecordByIdAction(
|
||||
}
|
||||
|
||||
const record = await getGradeRecordById(parsed.data.id)
|
||||
|
||||
// Row-level scope check for the fetched record
|
||||
if (record) {
|
||||
if (ctx.dataScope.type === "class_members" && record.studentId !== ctx.userId) {
|
||||
return { success: false, message: "You can only view your own grade records" }
|
||||
}
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(record.studentId)
|
||||
) {
|
||||
return { success: false, message: "You can only view your children's grade records" }
|
||||
}
|
||||
// P3 修复:对 class_taught scope 校验 record.classId 是否在 scope.classIds 中
|
||||
if (
|
||||
ctx.dataScope.type === "class_taught" &&
|
||||
!ctx.dataScope.classIds.includes(record.classId)
|
||||
) {
|
||||
return { success: false, message: "You can only view grade records of classes you teach" }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: record }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +581,8 @@ export async function getGradeRecordByIdAction(
|
||||
* 导出成绩单(返回 base64 编码的 Excel)
|
||||
*/
|
||||
export async function exportGradesAction(params: {
|
||||
classId: string
|
||||
classId?: string
|
||||
studentId?: string
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
reportType?: "detail" | "class"
|
||||
@@ -362,6 +599,37 @@ export async function exportGradesAction(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// v4-P1-12: 支持按 studentId 导出(家长视角)
|
||||
// 当提供 studentId 且 scope 为 children 时,校验该学生属于家长的子女
|
||||
if (parsed.data.studentId && ctx.dataScope.type === "children") {
|
||||
if (!ctx.dataScope.childrenIds.includes(parsed.data.studentId)) {
|
||||
return { success: false, message: "Can only export your own children's grades" }
|
||||
}
|
||||
const buffer = await exportStudentGradeRecordsToExcel({
|
||||
studentId: parsed.data.studentId,
|
||||
subjectId: parsed.data.subjectId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
const filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
buffer: buffer.toString("base64"),
|
||||
filename,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// classId 必须存在(studentId 分支已返回)
|
||||
if (!parsed.data.classId) {
|
||||
return { success: false, message: "classId is required for class export" }
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
let buffer: Buffer
|
||||
let filename: string
|
||||
|
||||
@@ -369,6 +637,7 @@ export async function exportGradesAction(params: {
|
||||
buffer = await exportClassGradeReportToExcel({
|
||||
classId: parsed.data.classId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
|
||||
} else {
|
||||
@@ -377,6 +646,7 @@ export async function exportGradesAction(params: {
|
||||
subjectId: parsed.data.subjectId,
|
||||
examId: parsed.data.examId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||
}
|
||||
@@ -389,10 +659,99 @@ export async function exportGradesAction(params: {
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "导出失败" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- v3-P2-10: 服务端草稿自动保存 ---
|
||||
|
||||
/**
|
||||
* 保存成绩录入草稿到服务端(跨设备同步)。
|
||||
*/
|
||||
export async function saveGradeDraftAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
scores: Record<string, string>
|
||||
}): Promise<ActionState<{ saved: true }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
// 校验班级权限
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const data: GradeDraftData = {
|
||||
scores: params.scores,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
await saveGradeDraft({
|
||||
userId: ctx.userId,
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
data,
|
||||
})
|
||||
|
||||
return { success: true, data: { saved: true } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成绩录入草稿(跨设备恢复)。
|
||||
*/
|
||||
export async function getGradeDraftAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
}): Promise<ActionState<GradeDraftData | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
// 校验班级权限
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
const draft = await getGradeDraft({
|
||||
userId: ctx.userId,
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
})
|
||||
|
||||
return { success: true, data: draft }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成绩录入草稿(提交成功后调用)。
|
||||
*/
|
||||
export async function deleteGradeDraftAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
}): Promise<ActionState<{ deleted: true }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
// 校验班级权限
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
|
||||
await deleteGradeDraft({
|
||||
userId: ctx.userId,
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
})
|
||||
|
||||
return { success: true, data: { deleted: true } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
|
||||
@@ -6,23 +7,32 @@ interface AnalyticsFiltersProps {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
grades: Array<{ id: string; name: string }>
|
||||
subjects: Array<{ id: string; name: string }>
|
||||
exams: Array<{ id: string; title: string }>
|
||||
currentClassId: string
|
||||
currentSubjectId: string
|
||||
currentGradeId: string
|
||||
currentExamId: string
|
||||
currentSemester: string
|
||||
}
|
||||
|
||||
export function AnalyticsFilters({
|
||||
export async function AnalyticsFilters({
|
||||
classes,
|
||||
grades,
|
||||
subjects,
|
||||
exams,
|
||||
currentClassId,
|
||||
currentSubjectId,
|
||||
currentGradeId,
|
||||
}: AnalyticsFiltersProps): JSX.Element {
|
||||
currentExamId,
|
||||
currentSemester,
|
||||
}: AnalyticsFiltersProps): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
const buildHref = (overrides: {
|
||||
classId?: string
|
||||
subjectId?: string
|
||||
gradeId?: string
|
||||
examId?: string
|
||||
semester?: string
|
||||
}): string => {
|
||||
const params = new URLSearchParams()
|
||||
params.set(
|
||||
@@ -43,14 +53,35 @@ export function AnalyticsFilters({
|
||||
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
|
||||
)
|
||||
}
|
||||
// v3-P2-7: examId 筛选
|
||||
const examId = overrides.examId !== undefined ? overrides.examId : currentExamId
|
||||
if (examId && examId !== "all") {
|
||||
params.set("examId", examId)
|
||||
}
|
||||
// v3-P2-7: semester 筛选
|
||||
const semester = overrides.semester !== undefined ? overrides.semester : currentSemester
|
||||
if (semester && semester !== "all") {
|
||||
params.set("semester", semester)
|
||||
}
|
||||
return `/teacher/grades/analytics?${params.toString()}`
|
||||
}
|
||||
|
||||
const semesterOptions = [
|
||||
{ id: "all", name: t("analytics.semesterAll") },
|
||||
{ id: "1", name: t("analytics.semester1") },
|
||||
{ id: "2", name: t("analytics.semester2") },
|
||||
]
|
||||
|
||||
const examOptions = [
|
||||
{ id: "all", name: t("analytics.examAll") },
|
||||
...exams.map((e) => ({ id: e.id, name: e.title })),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Class</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">{t("analytics.class")}</div>
|
||||
<ChipNav
|
||||
options={classes}
|
||||
currentId={currentClassId}
|
||||
@@ -61,20 +92,20 @@ export function AnalyticsFilters({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Subject</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">{t("analytics.subject")}</div>
|
||||
<ChipNav
|
||||
options={subjects}
|
||||
currentId={currentSubjectId}
|
||||
buildHref={(id) => buildHref({ subjectId: id })}
|
||||
size="xs"
|
||||
allOption={{ id: "all", label: "All" }}
|
||||
allOption={{ id: "all", label: t("analytics.allOption") }}
|
||||
className="gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Grade (for class comparison)
|
||||
{t("analytics.classComparisonLabel")}
|
||||
</div>
|
||||
<ChipNav
|
||||
options={grades}
|
||||
@@ -85,6 +116,35 @@ export function AnalyticsFilters({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v3-P2-7: 学期和考试筛选 */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 border-t pt-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{t("analytics.semester")}</div>
|
||||
<ChipNav
|
||||
options={semesterOptions}
|
||||
currentId={currentSemester}
|
||||
buildHref={(id) => buildHref({ semester: id })}
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{t("analytics.exam")}</div>
|
||||
{examOptions.length > 1 ? (
|
||||
<ChipNav
|
||||
options={examOptions}
|
||||
currentId={currentExamId}
|
||||
buildHref={(id) => buildHref({ examId: id })}
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t("analytics.noExams")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ 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 { useTranslations } from "next-intl"
|
||||
import { Search, TrendingUp, Trophy, AlertCircle, Download, Info, X } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -20,30 +21,60 @@ import {
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
|
||||
import { batchCreateGradeRecordsAction } from "../actions"
|
||||
import { batchCreateGradeRecordsAction, undoBatchCreateGradeRecordsAction, saveGradeDraftAction, getGradeDraftAction, deleteGradeDraftAction } from "../actions"
|
||||
import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types"
|
||||
import { isGradeType, isSemester } from "../lib/type-guards"
|
||||
|
||||
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"
|
||||
interface DraftData {
|
||||
scores: Record<string, string>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function isSemester(v: string): v is Semester {
|
||||
return v === "1" || v === "2"
|
||||
interface UndoData {
|
||||
ids: string[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** 类型守卫:验证 localStorage 草稿数据结构 */
|
||||
function isDraftData(v: unknown): v is DraftData {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 转换为 Record 以访问属性(已通过对象检查)
|
||||
const obj = v as Record<string, unknown>
|
||||
if (typeof obj.timestamp !== "number") return false
|
||||
if (typeof obj.scores !== "object" || obj.scores === null) return false
|
||||
// 从 unknown 转换:已通过对象检查
|
||||
const scores = obj.scores as Record<string, unknown>
|
||||
return Object.values(scores).every((val) => typeof val === "string")
|
||||
}
|
||||
|
||||
/** 类型守卫:验证 sessionStorage 撤销数据结构 */
|
||||
function isUndoData(v: unknown): v is UndoData {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 转换为 Record 以访问属性(已通过对象检查)
|
||||
const obj = v as Record<string, unknown>
|
||||
if (typeof obj.timestamp !== "number") return false
|
||||
if (!Array.isArray(obj.ids)) return false
|
||||
return obj.ids.every((id) => typeof id === "string")
|
||||
}
|
||||
|
||||
const MAX_SCORE = 100
|
||||
const DRAFT_KEY_PREFIX = "grade-draft"
|
||||
|
||||
/** 将创建的记录 ID 序列化为可撤销的 sessionStorage 数据(提取到组件外部避免 purity lint) */
|
||||
function serializeUndoData(ids: string[]): string {
|
||||
return JSON.stringify({ ids, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const t = useTranslations("grades")
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save All Grades"}
|
||||
{pending ? t("batch.saving") : t("batch.saveAllGrades")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -55,26 +86,33 @@ export function BatchGradeEntry({
|
||||
defaultClassId,
|
||||
defaultSubjectId,
|
||||
}: {
|
||||
classes: Option[]
|
||||
subjects: Option[]
|
||||
classes: SelectOption[]
|
||||
subjects: SelectOption[]
|
||||
students: Student[]
|
||||
defaultClassId?: string
|
||||
defaultSubjectId?: string
|
||||
}) {
|
||||
const t = useTranslations("grades")
|
||||
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<GradeType>("exam")
|
||||
const [semester, setSemester] = useState<Semester>("1")
|
||||
const [type, setType] = useState<GradeRecordType>("exam")
|
||||
const [semester, setSemester] = useState<GradeRecordSemester>("1")
|
||||
const [scores, setScores] = useState<Record<string, string>>(() => {
|
||||
// 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染)
|
||||
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
|
||||
if (typeof window === "undefined") return {}
|
||||
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
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (
|
||||
isDraftData(parsed) &&
|
||||
Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 &&
|
||||
Object.keys(parsed.scores).length > 0
|
||||
) {
|
||||
return parsed.scores
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -84,11 +122,18 @@ export function BatchGradeEntry({
|
||||
})
|
||||
const [draftRestored] = useState(() => {
|
||||
// 检查是否恢复了草稿(用于显示 toast)
|
||||
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
|
||||
if (typeof window === "undefined") return false
|
||||
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
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (isDraftData(parsed)) {
|
||||
return (
|
||||
Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 &&
|
||||
Object.keys(parsed.scores).length > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,忽略
|
||||
@@ -101,12 +146,42 @@ export function BatchGradeEntry({
|
||||
|
||||
const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}`
|
||||
|
||||
// P3-12 修复:使用 ref 保存最新 scores,避免草稿保存 useEffect 依赖 scores 导致每次按键触发重建定时器
|
||||
const scoresRef = useRef(scores)
|
||||
useEffect(() => {
|
||||
scoresRef.current = scores
|
||||
}, [scores])
|
||||
|
||||
// 草稿恢复提示(仅在首次挂载时显示一次)
|
||||
useEffect(() => {
|
||||
if (draftRestored) {
|
||||
toast.info("已恢复未保存的成绩草稿")
|
||||
toast.info(t("batch.restored"))
|
||||
}
|
||||
}, [draftRestored])
|
||||
}, [draftRestored, t])
|
||||
|
||||
// v3-P2-10: 服务端草稿恢复(localStorage 无草稿时,尝试从服务端恢复)
|
||||
useEffect(() => {
|
||||
if (draftRestored) return // 本地已恢复,跳过
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const result = await safeActionCall(() =>
|
||||
getGradeDraftAction({ classId, subjectId, type })
|
||||
)
|
||||
if (cancelled || !result || !result.success || !result.data) return
|
||||
|
||||
const draft = result.data
|
||||
// 24 小时过期检查(服务端已检查,这里双重校验)
|
||||
if (Date.now() - draft.timestamp > 24 * 60 * 60 * 1000) return
|
||||
if (Object.keys(draft.scores).length === 0) return
|
||||
|
||||
setScores(draft.scores)
|
||||
toast.info(t("batch.restoredFromServer"))
|
||||
})()
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [classId, subjectId, type, draftRestored, t])
|
||||
|
||||
const handleScoreChange = useCallback((studentId: string, value: string) => {
|
||||
if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) {
|
||||
@@ -114,19 +189,25 @@ export function BatchGradeEntry({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateScore = (value: string): boolean => {
|
||||
const validateScore = useCallback((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 => {
|
||||
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
|
||||
if (typeof window === "undefined") return false
|
||||
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)
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (
|
||||
isDraftData(parsed) &&
|
||||
Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 &&
|
||||
Object.keys(parsed.scores).length > 0
|
||||
) {
|
||||
setScores(parsed.scores)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -139,7 +220,7 @@ export function BatchGradeEntry({
|
||||
const handleClassChange = (newClassId: string) => {
|
||||
const hasUnsaved = Object.keys(scores).length > 0
|
||||
if (hasUnsaved && newClassId !== classId) {
|
||||
if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) {
|
||||
if (!window.confirm(t("batch.confirmSwitchClass"))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -148,7 +229,7 @@ export function BatchGradeEntry({
|
||||
// 切换班级后尝试恢复该班级的草稿
|
||||
const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}`
|
||||
if (restoreDraft(newDraftKey)) {
|
||||
toast.info("已恢复未保存的成绩草稿")
|
||||
toast.info(t("batch.restored"))
|
||||
}
|
||||
const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry"
|
||||
router.push(newUrl)
|
||||
@@ -159,6 +240,69 @@ export function BatchGradeEntry({
|
||||
[students, searchQuery]
|
||||
)
|
||||
|
||||
/**
|
||||
* Excel 粘贴处理器:从剪贴板解析一列分数,按当前学生列表顺序填充。
|
||||
* 支持格式:
|
||||
* - 单列分数(每行一个数字)
|
||||
* - 多列(取第一列或第一个可解析为数字的列)
|
||||
* - Tab/逗号/空格分隔
|
||||
*/
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement>, startIndex: number) => {
|
||||
const text = e.clipboardData.getData("text")
|
||||
if (!text) return
|
||||
|
||||
// 按行分割(兼容 \r\n 和 \n)
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
|
||||
if (lines.length === 0) return
|
||||
|
||||
// 尝试从每行提取数字(支持单列和多列格式)
|
||||
const values: string[] = []
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
// 尝试 Tab/逗号分隔,取第一个可解析为数字的部分
|
||||
const parts = trimmed.split(/[\t,]/).map((p) => p.trim())
|
||||
let found: string | null = null
|
||||
for (const part of parts) {
|
||||
if (part !== "" && /^\d*\.?\d+$/.test(part)) {
|
||||
found = part
|
||||
break
|
||||
}
|
||||
}
|
||||
// 如果多列没匹配到,尝试整行作为数字
|
||||
if (found === null && /^\d*\.?\d+$/.test(trimmed)) {
|
||||
found = trimmed
|
||||
}
|
||||
if (found !== null) values.push(found)
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
toast.error(t("batch.pasteNoMatch"))
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// 按当前过滤后的学生列表顺序填充
|
||||
const targetStudents = filteredStudents.slice(startIndex)
|
||||
const newScores: Record<string, string> = {}
|
||||
let appliedCount = 0
|
||||
for (let i = 0; i < Math.min(values.length, targetStudents.length); i += 1) {
|
||||
const student = targetStudents[i]
|
||||
if (!student) break
|
||||
const val = values[i]
|
||||
// 校验分数格式(与 handleScoreChange 一致)
|
||||
if (/^\d*\.?\d{0,2}$/.test(val)) {
|
||||
newScores[student.id] = val
|
||||
appliedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedCount > 0) {
|
||||
setScores((prev) => ({ ...prev, ...newScores }))
|
||||
toast.success(t("batch.pasteApplied", { count: appliedCount }))
|
||||
e.preventDefault()
|
||||
}
|
||||
}, [filteredStudents, t])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const validScores = students
|
||||
.map((s) => scores[s.id])
|
||||
@@ -174,32 +318,101 @@ export function BatchGradeEntry({
|
||||
min: Math.min(...validScores),
|
||||
total: students.length,
|
||||
}
|
||||
}, [scores, students])
|
||||
}, [scores, students, validateScore])
|
||||
|
||||
// v3-P3-1: 下载成绩录入模板(CSV,含学生姓名列表,支持 Excel 粘贴)
|
||||
const handleDownloadTemplate = useCallback(() => {
|
||||
const headers = [t("batch.templateStudentName"), t("batch.templateScore"), t("batch.templateRemark")]
|
||||
const rows = students.map((s) => [s.name, "", ""])
|
||||
const csv = [headers, ...rows]
|
||||
.map((r) => r.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n")
|
||||
// 添加 BOM 以支持 Excel 正确识别 UTF-8
|
||||
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = t("batch.templateFilename")
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [students, t])
|
||||
|
||||
// v4-P3-2: 新手引导提示框,使用 localStorage 记住用户是否已关闭
|
||||
const [guideVisible, setGuideVisible] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return true
|
||||
try {
|
||||
return localStorage.getItem("grade-entry-guide-dismissed") !== "true"
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const handleDismissGuide = useCallback(() => {
|
||||
setGuideVisible(false)
|
||||
try {
|
||||
localStorage.setItem("grade-entry-guide-dismissed", "true")
|
||||
} catch {
|
||||
// localStorage 不可用时静默失败
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v))
|
||||
|
||||
// 草稿保存到 localStorage(30秒间隔)
|
||||
// v3-P2-10: 草稿保存到 localStorage(30秒间隔)+ 服务端同步(60秒间隔)
|
||||
// P3-12 修复:使用 scoresRef 读取最新 scores,定时器不再依赖 scores,避免每次按键重建定时器
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (Object.keys(scores).length > 0) {
|
||||
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
// 本地草稿:30 秒间隔,快速恢复
|
||||
const localInterval = setInterval(() => {
|
||||
const currentScores = scoresRef.current
|
||||
if (Object.keys(currentScores).length > 0) {
|
||||
try {
|
||||
localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() }))
|
||||
localStorage.setItem(draftKey, JSON.stringify({ scores: currentScores, timestamp: Date.now() }))
|
||||
} catch {
|
||||
// localStorage 可能已满或不可用,静默失败
|
||||
}
|
||||
}
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [scores, draftKey])
|
||||
|
||||
// 清除草稿
|
||||
// 服务端草稿:60 秒间隔,跨设备同步
|
||||
const serverInterval = setInterval(() => {
|
||||
const currentScores = scoresRef.current
|
||||
if (Object.keys(currentScores).length > 0) {
|
||||
void safeActionCall(() =>
|
||||
saveGradeDraftAction({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
scores: currentScores,
|
||||
})
|
||||
)
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(localInterval)
|
||||
clearInterval(serverInterval)
|
||||
}
|
||||
}, [draftKey, classId, subjectId, type])
|
||||
|
||||
// 清除草稿(本地 + 服务端)
|
||||
const clearDraft = useCallback(() => {
|
||||
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.removeItem(draftKey)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}, [draftKey])
|
||||
// v3-P2-10: 同步清除服务端草稿
|
||||
void safeActionCall(() =>
|
||||
deleteGradeDraftAction({ classId, subjectId, type })
|
||||
)
|
||||
}, [draftKey, classId, subjectId, type])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, studentId: string) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -215,23 +428,31 @@ export function BatchGradeEntry({
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId || !subjectId) {
|
||||
toast.error("Please select class and subject")
|
||||
toast.error(t("batch.selectClassAndSubject"))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasInvalidScores) {
|
||||
toast.error("存在无效分数(超过满分或格式错误),请检查后重试")
|
||||
toast.error(t("batch.invalidScoresError"))
|
||||
return
|
||||
}
|
||||
|
||||
// P3 修复:区分"未输入"和"输入 0"
|
||||
// 只有当 scores[studentId] 有值且非空字符串时才视为已输入
|
||||
// 这样输入 0 会被正确提交,而未输入会被跳过
|
||||
const records = students
|
||||
.map((s) => ({
|
||||
.map((s) => {
|
||||
const raw = scores[s.id]
|
||||
if (raw === undefined || raw === "") return null
|
||||
return {
|
||||
studentId: s.id,
|
||||
score: Number(scores[s.id] ?? 0),
|
||||
}))
|
||||
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
|
||||
score: Number(raw),
|
||||
}
|
||||
})
|
||||
.filter((r): r is { studentId: string; score: number } => r !== null)
|
||||
|
||||
if (records.length === 0) {
|
||||
toast.error("Please enter at least one score")
|
||||
toast.error(t("batch.enterAtLeastOne"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,42 +463,156 @@ export function BatchGradeEntry({
|
||||
formData.set("semester", semester)
|
||||
formData.set("recordsJson", JSON.stringify(records))
|
||||
|
||||
const result = await batchCreateGradeRecordsAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
// P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态
|
||||
const result = await safeActionCall(
|
||||
() => batchCreateGradeRecordsAction(null, formData),
|
||||
{
|
||||
onError: () => toast.error(t("error.saveFailed")),
|
||||
onFinally: () => setIsSubmitting(false),
|
||||
}
|
||||
)
|
||||
if (result?.success) {
|
||||
clearDraft()
|
||||
// v3-P2-3:保存创建的记录 ID 到 sessionStorage,供撤销使用
|
||||
const createdIds = result.data ?? []
|
||||
if (createdIds.length > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"lastBatchGradeRecordIds",
|
||||
serializeUndoData(createdIds)
|
||||
)
|
||||
} catch {
|
||||
// sessionStorage 不可用时静默失败(不影响主流程)
|
||||
}
|
||||
// 显示带撤销按钮的 toast,10 秒内可撤销
|
||||
toast.success(result.message, {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: t("batch.undo"),
|
||||
onClick: () => handleUndo(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
toast.success(result.message)
|
||||
}
|
||||
router.push("/teacher/grades")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save")
|
||||
} else if (result) {
|
||||
toast.error(result.message || t("error.saveFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-3: 撤销最近一次批量录入。
|
||||
* 从 sessionStorage 读取上次创建的记录 ID 列表,调用 undo action 删除。
|
||||
*/
|
||||
const handleUndo = useCallback(async () => {
|
||||
if (typeof window === "undefined") return
|
||||
let ids: string[] = []
|
||||
try {
|
||||
const raw = sessionStorage.getItem("lastBatchGradeRecordIds")
|
||||
if (!raw) {
|
||||
toast.error(t("batch.undoNoRecord"))
|
||||
return
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isUndoData(parsed)) {
|
||||
toast.error(t("batch.undoNoRecord"))
|
||||
return
|
||||
}
|
||||
// 仅允许 5 分钟内的撤销
|
||||
if (Date.now() - parsed.timestamp > 5 * 60 * 1000) {
|
||||
sessionStorage.removeItem("lastBatchGradeRecordIds")
|
||||
toast.error(t("batch.undoExpired"))
|
||||
return
|
||||
}
|
||||
ids = parsed.ids
|
||||
} catch {
|
||||
toast.error(t("batch.undoNoRecord"))
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error(t("batch.undoNoRecord"))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await safeActionCall(
|
||||
() => undoBatchCreateGradeRecordsAction(ids),
|
||||
{
|
||||
onError: () => toast.error(t("batch.undoFailed")),
|
||||
}
|
||||
)
|
||||
if (result?.success) {
|
||||
try {
|
||||
sessionStorage.removeItem("lastBatchGradeRecordIds")
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
toast.success(result.message)
|
||||
router.refresh()
|
||||
} else if (result) {
|
||||
toast.error(result.message || t("batch.undoFailed"))
|
||||
}
|
||||
}, [router, t])
|
||||
|
||||
return (
|
||||
<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...
|
||||
{t("batch.savingGrades")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>Batch Grade Entry</CardTitle>
|
||||
<CardTitle>{t("batch.title")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
满分 {MAX_SCORE} 分。输入分数后按 Enter 跳到下一位学生。草稿每 30 秒自动保存,2 小时内有效。
|
||||
{t("batch.fullScoreHint", { max: MAX_SCORE })}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* v4-P3-2: 新手引导提示框 */}
|
||||
{guideVisible && (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("batch.guide.title")}
|
||||
className="mb-6 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" aria-hidden="true" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
|
||||
{t("batch.guide.title")}
|
||||
</h3>
|
||||
<ol className="list-decimal space-y-1 pl-4 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>{t("batch.guide.step1")}</li>
|
||||
<li>{t("batch.guide.step2")}</li>
|
||||
<li>{t("batch.guide.step3")}</li>
|
||||
<li>{t("batch.guide.step4")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-blue-600 hover:bg-blue-100 hover:text-blue-700 dark:text-blue-400 dark:hover:bg-blue-900"
|
||||
onClick={handleDismissGuide}
|
||||
aria-label={t("batch.guide.dismissAriaLabel")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<Label htmlFor="batch-class">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={handleClassChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectTrigger id="batch-class">
|
||||
<SelectValue placeholder={t("form.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -290,10 +625,10 @@ export function BatchGradeEntry({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Subject</Label>
|
||||
<Label htmlFor="batch-subject">{t("filters.subject")}</Label>
|
||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
<SelectTrigger id="batch-subject">
|
||||
<SelectValue placeholder={t("form.selectSubject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
@@ -306,53 +641,53 @@ export function BatchGradeEntry({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="title">Exam / Quiz Title</Label>
|
||||
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
|
||||
<Label htmlFor="title">{t("batch.examQuizTitle")}</Label>
|
||||
<Input id="title" name="title" placeholder={t("form.titlePlaceholder")} required />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fullScore">Full Score</Label>
|
||||
<Label htmlFor="fullScore">{t("form.fullScore")}</Label>
|
||||
<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>
|
||||
<Label htmlFor="batch-type">{t("filters.type")}</Label>
|
||||
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="batch-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exam">Exam</SelectItem>
|
||||
<SelectItem value="quiz">Quiz</SelectItem>
|
||||
<SelectItem value="homework">Homework</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
<SelectItem value="exam">{t("type.exam")}</SelectItem>
|
||||
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
|
||||
<SelectItem value="homework">{t("type.homework")}</SelectItem>
|
||||
<SelectItem value="other">{t("type.other")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Semester</Label>
|
||||
<Label htmlFor="batch-semester">{t("filters.semester")}</Label>
|
||||
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="batch-semester">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Semester 1</SelectItem>
|
||||
<SelectItem value="2">Semester 2</SelectItem>
|
||||
<SelectItem value="1">{t("semester.s1")}</SelectItem>
|
||||
<SelectItem value="2">{t("semester.s2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{students.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No students in this class.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("batch.noStudentsInClass")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* 实时统计栏 */}
|
||||
<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="text-muted-foreground">{t("batch.entered")}</span>
|
||||
<span className="font-semibold tabular-nums">{stats.entered}</span>
|
||||
<span className="text-muted-foreground">/ {stats.total}</span>
|
||||
</span>
|
||||
@@ -360,33 +695,47 @@ export function BatchGradeEntry({
|
||||
<>
|
||||
<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="text-muted-foreground">{t("batch.average")}</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="text-muted-foreground">{t("batch.max")}</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="text-muted-foreground">{t("batch.min")}</span>
|
||||
<span className="font-semibold tabular-nums">{stats.min}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{t("batch.pasteHint")}</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" />
|
||||
存在无效分数
|
||||
{t("batch.invalidScoresBadge")}
|
||||
</span>
|
||||
)}
|
||||
{/* v3-P3-1: 下载成绩录入模板 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleDownloadTemplate}
|
||||
aria-label={t("batch.templateAriaLabel")}
|
||||
disabled={students.length === 0}
|
||||
>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("batch.downloadTemplate")}
|
||||
</Button>
|
||||
<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..."
|
||||
placeholder={t("batch.searchStudent")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-40 pl-8 text-sm"
|
||||
@@ -396,12 +745,13 @@ export function BatchGradeEntry({
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<caption className="sr-only">{t("batch.caption")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||
<TableHead className="w-32">Score</TableHead>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("batch.emailColumn")}</TableHead>
|
||||
<TableHead className="w-32">{t("batch.score")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -424,6 +774,7 @@ export function BatchGradeEntry({
|
||||
value={scoreValue}
|
||||
onChange={(e) => handleScoreChange(s.id, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, s.id)}
|
||||
onPaste={(e) => handlePaste(e, idx)}
|
||||
className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")}
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
@@ -439,7 +790,7 @@ export function BatchGradeEntry({
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
{t("batch.cancel")}
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
|
||||
@@ -1,16 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3, ChevronDown, Info } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ClassComparisonItem } from "@/modules/grades/types"
|
||||
|
||||
interface ClassComparisonChartProps {
|
||||
data: ClassComparisonItem[]
|
||||
}
|
||||
|
||||
export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
|
||||
type SignificanceLevel = "high" | "medium" | "low"
|
||||
|
||||
interface SignificanceResult {
|
||||
range: number
|
||||
level: SignificanceLevel
|
||||
topClass: ClassComparisonItem
|
||||
bottomClass: ClassComparisonItem
|
||||
}
|
||||
|
||||
/** 显著性判断阈值(经验规则,避免复杂统计计算) */
|
||||
const MIN_SAMPLE_SIZE = 30
|
||||
const HIGH_RANGE_THRESHOLD = 10
|
||||
const MEDIUM_RANGE_THRESHOLD = 5
|
||||
|
||||
/**
|
||||
* v3-P3-5: 班级对比显著性分析。
|
||||
* 基于极差和样本量的经验规则判断班级间差异是否具有统计意义。
|
||||
* - 极差 >= 10 且各班样本量 >= 30:显著差异
|
||||
* - 极差 >= 5:可能存在差异(含极差大但样本不足的情况)
|
||||
* - 极差 < 5:差异不显著
|
||||
*/
|
||||
function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult | null {
|
||||
if (data.length < 2) return null
|
||||
|
||||
let topClass = data[0]
|
||||
let bottomClass = data[0]
|
||||
for (const item of data) {
|
||||
if (item.averageScore > topClass.averageScore) topClass = item
|
||||
if (item.averageScore < bottomClass.averageScore) bottomClass = item
|
||||
}
|
||||
|
||||
const range = topClass.averageScore - bottomClass.averageScore
|
||||
const allSufficient = data.every((d) => d.studentCount >= MIN_SAMPLE_SIZE)
|
||||
|
||||
let level: SignificanceLevel
|
||||
if (range >= HIGH_RANGE_THRESHOLD && allSufficient) {
|
||||
level = "high"
|
||||
} else if (range >= MEDIUM_RANGE_THRESHOLD) {
|
||||
level = "medium"
|
||||
} else {
|
||||
level = "low"
|
||||
}
|
||||
|
||||
return { range, level, topClass, bottomClass }
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
return score.toFixed(1)
|
||||
}
|
||||
|
||||
export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
const chartData = isEmpty
|
||||
@@ -24,27 +85,47 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
|
||||
studentCount: d.studentCount,
|
||||
}))
|
||||
|
||||
const significance = isEmpty ? null : analyzeSignificance(data)
|
||||
|
||||
const levelColorClass: Record<SignificanceLevel, string> = {
|
||||
high: "border-destructive/30 bg-destructive/5 text-destructive",
|
||||
medium: "border-yellow-500/30 bg-yellow-500/5 text-yellow-700 dark:text-yellow-400",
|
||||
low: "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400",
|
||||
}
|
||||
|
||||
const levelLabel: Record<SignificanceLevel, string> = {
|
||||
high: t("classComparison.significanceHigh"),
|
||||
medium: t("classComparison.significanceMedium"),
|
||||
low: t("classComparison.significanceLow"),
|
||||
}
|
||||
|
||||
const levelHint: Record<SignificanceLevel, string> = {
|
||||
high: t("classComparison.significanceHighHint"),
|
||||
medium: t("classComparison.significanceMediumHint"),
|
||||
low: t("classComparison.significanceLowHint"),
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Class Comparison"
|
||||
title={t("analytics.classComparison")}
|
||||
description={
|
||||
isEmpty
|
||||
? "Compare average, pass rate, and excellent rate across classes."
|
||||
: "Average score, pass rate (≥60%), and excellent rate (≥85%) per class."
|
||||
? t("classComparison.descriptionEmpty")
|
||||
: t("classComparison.descriptionNonEmpty")
|
||||
}
|
||||
icon={BarChart3}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No comparison data"
|
||||
emptyDescription="Select a grade and subject to compare classes."
|
||||
emptyTitle={t("classComparison.emptyTitle")}
|
||||
emptyDescription={t("classComparison.emptyDescription")}
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<div role="img" aria-label={`班级对比柱状图:${isEmpty ? "暂无数据" : `共 ${data.length} 个班级的均分、及格率与优秀率对比`}`}>
|
||||
<div role="img" aria-label={isEmpty ? t("classComparison.ariaLabelEmpty") : t("classComparison.ariaLabelNonEmpty", { count: data.length })}>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
{ dataKey: "averageScore", name: t("chart.averagePercent"), color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: t("chart.passRatePercent"), color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: t("chart.excellentPercent"), color: "hsl(var(--chart-3))" },
|
||||
]}
|
||||
xKey="name"
|
||||
xTruncateLength={8}
|
||||
@@ -57,6 +138,57 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
|
||||
tooltipClassName="w-[240px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{significance ? (
|
||||
<div
|
||||
className="mt-3 space-y-2"
|
||||
aria-label={t("classComparison.significanceAriaLabel", { level: levelLabel[significance.level] })}
|
||||
>
|
||||
<div className={cn("flex items-start gap-2 rounded-md border p-3", levelColorClass[significance.level])}>
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="font-medium">
|
||||
{t("classComparison.significanceTitle")} · {levelLabel[significance.level]}
|
||||
</div>
|
||||
<div className="text-xs opacity-90">
|
||||
{t("classComparison.significanceRange", { range: formatScore(significance.range) })}
|
||||
</div>
|
||||
<div className="text-xs opacity-80">{levelHint[significance.level]}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-1 rounded-md border border-border/60 bg-muted/30 px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{t("classComparison.significanceDetails")}
|
||||
<ChevronDown
|
||||
className={cn("h-3 w-3 transition-transform", detailsOpen && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-1 rounded-md border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<div>
|
||||
{t("classComparison.significanceTopClass", {
|
||||
name: significance.topClass.className,
|
||||
score: formatScore(significance.topClass.averageScore),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("classComparison.significanceBottomClass", {
|
||||
name: significance.bottomClass.className,
|
||||
score: formatScore(significance.bottomClass.averageScore),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
@@ -19,7 +21,8 @@ interface ClassGradeReportProps {
|
||||
ranking: ClassRankingItem[]
|
||||
}
|
||||
|
||||
export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
|
||||
export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{stats ? (
|
||||
@@ -28,7 +31,7 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{stats.className}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stats.studentCount} students · {stats.stats.count} grade records
|
||||
{t("classReport.studentCountInfo", { studentCount: stats.studentCount, recordCount: stats.stats.count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,8 +39,8 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No data"
|
||||
description="No grade records found for this class."
|
||||
title={t("classReport.noDataTitle")}
|
||||
description={t("classReport.noDataDescription")}
|
||||
icon={Trophy}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -46,17 +49,18 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
|
||||
{ranking.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Class Ranking</CardTitle>
|
||||
<CardTitle>{t("classReport.classRanking")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<caption className="sr-only">{t("classReport.caption")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Rank</TableHead>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead className="text-right">Average Score</TableHead>
|
||||
<TableHead className="text-right">Records</TableHead>
|
||||
<TableHead className="w-16">{t("classReport.rankColumn")}</TableHead>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead className="text-right">{t("summary.averageScore")}</TableHead>
|
||||
<TableHead className="text-right">{t("classReport.recordsColumn")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { downloadBase64File } from "@/shared/lib/download"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
import { exportGradesAction } from "../actions"
|
||||
|
||||
type ExportButtonProps = {
|
||||
@@ -29,29 +31,37 @@ export function ExportButton({
|
||||
examId,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
label = "导出",
|
||||
label,
|
||||
}: ExportButtonProps) {
|
||||
const t = useTranslations("grades")
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExport = async (reportType: "detail" | "class") => {
|
||||
if (!classId) {
|
||||
toast.error("请先选择班级")
|
||||
toast.error(t("export.selectClassFirst"))
|
||||
return
|
||||
}
|
||||
setIsExporting(true)
|
||||
const result = await exportGradesAction({
|
||||
// P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态
|
||||
const result = await safeActionCall(
|
||||
() =>
|
||||
exportGradesAction({
|
||||
classId,
|
||||
subjectId,
|
||||
examId,
|
||||
reportType,
|
||||
})
|
||||
setIsExporting(false)
|
||||
}),
|
||||
{
|
||||
onError: () => toast.error(t("export.failed")),
|
||||
onFinally: () => setIsExporting(false),
|
||||
}
|
||||
)
|
||||
|
||||
if (result.success && result.data) {
|
||||
if (result?.success && result.data) {
|
||||
downloadBase64File(result.data.buffer, result.data.filename)
|
||||
toast.success("导出成功")
|
||||
} else {
|
||||
toast.error(result.message ?? "导出失败")
|
||||
toast.success(t("export.success"))
|
||||
} else if (result) {
|
||||
toast.error(result.message ?? t("export.failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +69,7 @@ export function ExportButton({
|
||||
return (
|
||||
<Button variant={variant} size={size} disabled>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
导出中...
|
||||
{t("export.exporting")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -69,15 +79,15 @@ export function ExportButton({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={variant} size={size}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
{label ?? t("export.defaultLabel")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleExport("detail")}>
|
||||
成绩明细
|
||||
{t("export.detailItem")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("class")}>
|
||||
班级成绩总表
|
||||
{t("export.classReportItem")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { PieChart as PieChartIcon } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
|
||||
import type { GradeDistributionResult } from "@/modules/grades/types"
|
||||
|
||||
const BUCKET_COLORS: Record<string, string> = {
|
||||
"90-100": "hsl(142, 71%, 45%)",
|
||||
"80-89": "hsl(217, 91%, 60%)",
|
||||
"70-79": "hsl(43, 96%, 56%)",
|
||||
"60-69": "hsl(25, 95%, 53%)",
|
||||
"<60": "hsl(0, 84%, 60%)",
|
||||
/**
|
||||
* v4-P3-4: 色盲友好的双重编码。
|
||||
* 每个分数段使用不同的 SVG pattern(条纹/点状/交叉线等)+ 颜色,
|
||||
* 确保色觉障碍用户能通过纹理区分各分数段。
|
||||
*/
|
||||
const BUCKET_FILLS: Record<string, string> = {
|
||||
"90-100": "url(#grade-pattern-90-100)",
|
||||
"80-89": "url(#grade-pattern-80-89)",
|
||||
"70-79": "url(#grade-pattern-70-79)",
|
||||
"60-69": "url(#grade-pattern-60-69)",
|
||||
"<60": "url(#grade-pattern-lt60)",
|
||||
}
|
||||
|
||||
/** v4-P3-4: SVG 图案定义,为每个分数段提供独特的纹理 */
|
||||
const PATTERN_DEFS = (
|
||||
<defs>
|
||||
{/* 90-100: 正向斜条纹 */}
|
||||
<pattern
|
||||
id="grade-pattern-90-100"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect width="8" height="8" fill="hsl(142, 71%, 45%)" />
|
||||
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(142, 71%, 25%)" strokeWidth="3" />
|
||||
</pattern>
|
||||
{/* 80-89: 圆点 */}
|
||||
<pattern
|
||||
id="grade-pattern-80-89"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="10"
|
||||
height="10"
|
||||
>
|
||||
<rect width="10" height="10" fill="hsl(217, 91%, 60%)" />
|
||||
<circle cx="5" cy="5" r="2.5" fill="hsl(217, 91%, 35%)" />
|
||||
</pattern>
|
||||
{/* 70-79: 交叉线 */}
|
||||
<pattern
|
||||
id="grade-pattern-70-79"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<rect width="8" height="8" fill="hsl(43, 96%, 56%)" />
|
||||
<path d="M0,0 L8,8 M8,0 L0,8" stroke="hsl(43, 96%, 31%)" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
{/* 60-69: 反向斜条纹 */}
|
||||
<pattern
|
||||
id="grade-pattern-60-69"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
patternTransform="rotate(-45)"
|
||||
>
|
||||
<rect width="8" height="8" fill="hsl(25, 95%, 53%)" />
|
||||
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(25, 95%, 33%)" strokeWidth="3" />
|
||||
</pattern>
|
||||
{/* <60: 网格 */}
|
||||
<pattern
|
||||
id="grade-pattern-lt60"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<rect width="8" height="8" fill="hsl(0, 84%, 60%)" />
|
||||
<path d="M0,0 L8,0 M0,0 L0,8" stroke="hsl(0, 84%, 40%)" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
)
|
||||
|
||||
interface GradeDistributionChartProps {
|
||||
data: GradeDistributionResult | null
|
||||
}
|
||||
@@ -43,6 +107,7 @@ function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPaylo
|
||||
}
|
||||
|
||||
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
const t = useTranslations("grades")
|
||||
const isEmpty = !data || data.totalCount === 0
|
||||
|
||||
const chartData = isEmpty
|
||||
@@ -58,25 +123,25 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Score Distribution"
|
||||
title={t("distribution.title")}
|
||||
description={
|
||||
isEmpty
|
||||
? "Number of students in each score range (normalized to 0-100)."
|
||||
: `${data.totalCount} grade record${data.totalCount === 1 ? "" : "s"} across score ranges.`
|
||||
? t("distribution.descriptionEmpty")
|
||||
: t("distribution.descriptionNonEmpty", { count: data.totalCount })
|
||||
}
|
||||
icon={PieChartIcon}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No distribution data"
|
||||
emptyDescription="Select a class and subject to view score distribution."
|
||||
emptyTitle={t("distribution.emptyTitle")}
|
||||
emptyDescription={t("distribution.emptyDescription")}
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<div role="img" aria-label={`分数分布柱状图:${isEmpty ? "暂无数据" : `共 ${data.totalCount} 条成绩记录分布在 5 个分数区间`}`}>
|
||||
<div role="img" aria-label={isEmpty ? t("distribution.ariaLabelEmpty") : t("distribution.ariaLabelNonEmpty", { count: data.totalCount })}>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "count",
|
||||
name: "Students",
|
||||
name: t("chart.students"),
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
@@ -87,7 +152,8 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
defs={PATTERN_DEFS}
|
||||
cellColors={BUCKET_FILLS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
if (!isDistributionTooltipPayload(payload)) return null
|
||||
const item = payload.payload
|
||||
@@ -95,9 +161,9 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{item.label}: {item.count} student{item.count === 1 ? "" : "s"}
|
||||
{item.label}: {t("distribution.tooltipStudents", { count: item.count })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.percentage}% of total</span>
|
||||
<span className="text-xs text-muted-foreground">{t("distribution.tooltipOfTotal", { percentage: item.percentage })}</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
92
src/modules/grades/components/grade-filters.tsx
Normal file
92
src/modules/grades/components/grade-filters.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
import type { SelectOption } from "../types"
|
||||
|
||||
/**
|
||||
* 学生端成绩过滤器。
|
||||
*
|
||||
* v3-P2-1:subjects 改为通过 prop 传入,使用 subjectId 作为 value,
|
||||
* 而非硬编码科目名称。若未传入 subjects,则不显示科目筛选。
|
||||
*/
|
||||
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
|
||||
const t = useTranslations("grades")
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
|
||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||
const [semester, setSemester] = useQueryState("semester", parseAsString.withDefault("all"))
|
||||
|
||||
const hasFilters = Boolean(search || subject !== "all" || type !== "all" || semester !== "all")
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
layout="between"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setSearch(null)
|
||||
setSubject(null)
|
||||
setType(null)
|
||||
setSemester(null)
|
||||
}}
|
||||
>
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder={t("filters.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
{subjects && subjects.length > 0 && (
|
||||
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("filters.subject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allSubjects")}</SelectItem>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("filters.type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
|
||||
<SelectItem value="exam">{t("type.exam")}</SelectItem>
|
||||
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
|
||||
<SelectItem value="homework">{t("type.homework")}</SelectItem>
|
||||
<SelectItem value="other">{t("type.other")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={semester} onValueChange={(val) => setSemester(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("filters.semester")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allSemesters")}</SelectItem>
|
||||
<SelectItem value="1">{t("semester.s1")}</SelectItem>
|
||||
<SelectItem value="2">{t("semester.s2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
|
||||
type Option = { id: string; name: string }
|
||||
import type { SelectOption } from "../types"
|
||||
|
||||
interface GradeQueryFiltersProps {
|
||||
classes: Option[]
|
||||
subjects: Option[]
|
||||
classes: SelectOption[]
|
||||
subjects: SelectOption[]
|
||||
}
|
||||
|
||||
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -37,13 +39,13 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Class</Label>
|
||||
<Label htmlFor="filter-class" className="text-xs">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All classes" />
|
||||
<SelectTrigger id="filter-class" className="h-9">
|
||||
<SelectValue placeholder={t("filters.allClasses")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All classes</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allClasses")}</SelectItem>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -54,13 +56,13 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Subject</Label>
|
||||
<Label htmlFor="filter-subject" className="text-xs">{t("filters.subject")}</Label>
|
||||
<Select value={subjectId} onValueChange={(v) => updateParam("subjectId", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All subjects" />
|
||||
<SelectTrigger id="filter-subject" className="h-9">
|
||||
<SelectValue placeholder={t("filters.allSubjects")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All subjects</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allSubjects")}</SelectItem>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
@@ -71,31 +73,31 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Label htmlFor="filter-type" className="text-xs">{t("filters.type")}</Label>
|
||||
<Select value={type} onValueChange={(v) => updateParam("type", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All types" />
|
||||
<SelectTrigger id="filter-type" className="h-9">
|
||||
<SelectValue placeholder={t("filters.allTypes")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="exam">Exam</SelectItem>
|
||||
<SelectItem value="quiz">Quiz</SelectItem>
|
||||
<SelectItem value="homework">Homework</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
|
||||
<SelectItem value="exam">{t("type.exam")}</SelectItem>
|
||||
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
|
||||
<SelectItem value="homework">{t("type.homework")}</SelectItem>
|
||||
<SelectItem value="other">{t("type.other")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Semester</Label>
|
||||
<Label htmlFor="filter-semester" className="text-xs">{t("filters.semester")}</Label>
|
||||
<Select value={semester} onValueChange={(v) => updateParam("semester", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All semesters" />
|
||||
<SelectTrigger id="filter-semester" className="h-9">
|
||||
<SelectValue placeholder={t("filters.allSemesters")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All semesters</SelectItem>
|
||||
<SelectItem value="1">Semester 1</SelectItem>
|
||||
<SelectItem value="2">Semester 2</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allSemesters")}</SelectItem>
|
||||
<SelectItem value="1">{t("semester.s1")}</SelectItem>
|
||||
<SelectItem value="2">{t("semester.s2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -11,26 +12,18 @@ import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
|
||||
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"
|
||||
}
|
||||
import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types"
|
||||
import { isGradeType, isSemester } from "../lib/type-guards"
|
||||
|
||||
function SubmitButton() {
|
||||
const t = useTranslations("grades")
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save Record"}
|
||||
{pending ? t("form.saving") : t("form.save")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -42,22 +35,23 @@ export function GradeRecordForm({
|
||||
defaultClassId,
|
||||
defaultSubjectId,
|
||||
}: {
|
||||
classes: Option[]
|
||||
subjects: Option[]
|
||||
students: Option[]
|
||||
classes: SelectOption[]
|
||||
subjects: SelectOption[]
|
||||
students: SelectOption[]
|
||||
defaultClassId?: string
|
||||
defaultSubjectId?: string
|
||||
}) {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
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<GradeType>("exam")
|
||||
const [semester, setSemester] = useState<Semester>("1")
|
||||
const [type, setType] = useState<GradeRecordType>("exam")
|
||||
const [semester, setSemester] = useState<GradeRecordSemester>("1")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId || !subjectId || !studentId) {
|
||||
toast.error("Please select class, subject and student")
|
||||
toast.error(t("form.selectPrompt"))
|
||||
return
|
||||
}
|
||||
formData.set("classId", classId)
|
||||
@@ -66,29 +60,35 @@ export function GradeRecordForm({
|
||||
formData.set("type", type)
|
||||
formData.set("semester", semester)
|
||||
|
||||
const result = await createGradeRecordAction(null, formData)
|
||||
if (result.success) {
|
||||
// P3 修复:使用 safeActionCall 包装,确保异常时也能正确处理
|
||||
const result = await safeActionCall(
|
||||
() => createGradeRecordAction(null, formData),
|
||||
{
|
||||
onError: () => toast.error(t("error.failedToCreate")),
|
||||
}
|
||||
)
|
||||
if (result?.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/grades")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to create")
|
||||
} else if (result) {
|
||||
toast.error(result.message || t("error.failedToCreate"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Grade</CardTitle>
|
||||
<CardTitle>{t("form.title")}</CardTitle>
|
||||
</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>
|
||||
<Label htmlFor="record-class">{t("filters.class")}</Label>
|
||||
<Select value={classId} onValueChange={setClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectTrigger id="record-class">
|
||||
<SelectValue placeholder={t("form.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -101,10 +101,10 @@ export function GradeRecordForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Subject</Label>
|
||||
<Label htmlFor="record-subject">{t("filters.subject")}</Label>
|
||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
<SelectTrigger id="record-subject">
|
||||
<SelectValue placeholder={t("form.selectSubject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
@@ -117,10 +117,10 @@ export function GradeRecordForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Student</Label>
|
||||
<Label htmlFor="record-student">{t("form.student")}</Label>
|
||||
<Select value={studentId} onValueChange={setStudentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a student" />
|
||||
<SelectTrigger id="record-student">
|
||||
<SelectValue placeholder={t("form.selectStudent")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{students.map((s) => (
|
||||
@@ -133,57 +133,57 @@ export function GradeRecordForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
|
||||
<Label htmlFor="title">{t("form.titleLabel")}</Label>
|
||||
<Input id="title" name="title" placeholder={t("form.titlePlaceholder")} required />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="score">Score</Label>
|
||||
<Label htmlFor="score">{t("form.score")}</Label>
|
||||
<Input id="score" name="score" type="number" step="0.01" min="0" required />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fullScore">Full Score</Label>
|
||||
<Label htmlFor="fullScore">{t("form.fullScore")}</Label>
|
||||
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Type</Label>
|
||||
<Label htmlFor="record-type">{t("filters.type")}</Label>
|
||||
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="record-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exam">Exam</SelectItem>
|
||||
<SelectItem value="quiz">Quiz</SelectItem>
|
||||
<SelectItem value="homework">Homework</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
<SelectItem value="exam">{t("type.exam")}</SelectItem>
|
||||
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
|
||||
<SelectItem value="homework">{t("type.homework")}</SelectItem>
|
||||
<SelectItem value="other">{t("type.other")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Semester</Label>
|
||||
<Label htmlFor="record-semester">{t("filters.semester")}</Label>
|
||||
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="record-semester">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Semester 1</SelectItem>
|
||||
<SelectItem value="2">Semester 2</SelectItem>
|
||||
<SelectItem value="1">{t("semester.s1")}</SelectItem>
|
||||
<SelectItem value="2">{t("semester.s2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="remark">Remark (optional)</Label>
|
||||
<Textarea id="remark" name="remark" placeholder="Notes about this grade..." />
|
||||
<Label htmlFor="remark">{t("form.remark")}</Label>
|
||||
<Textarea id="remark" name="remark" placeholder={t("form.remarkPlaceholder")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end gap-2 px-0">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
|
||||
@@ -3,7 +3,19 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,35 +34,149 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
import { Trash2, Pencil } from "lucide-react"
|
||||
|
||||
import { deleteGradeRecordAction } from "../actions"
|
||||
import type { GradeRecordListItem } from "../types"
|
||||
import { deleteGradeRecordAction, updateGradeRecordAction, bulkDeleteGradeRecordsAction } from "../actions"
|
||||
import type { GradeRecordListItem, GradeRecordType, GradeRecordSemester } from "../types"
|
||||
import { GRADE_TYPE_VARIANT } from "../types"
|
||||
import { isGradeType, isSemester } from "../lib/type-guards"
|
||||
import { ScoreCell } from "./score-cell"
|
||||
|
||||
type EditableFields = {
|
||||
id: string
|
||||
title: string
|
||||
score: string
|
||||
fullScore: string
|
||||
type: GradeRecordType
|
||||
semester: GradeRecordSemester
|
||||
remark: string
|
||||
}
|
||||
|
||||
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<EditableFields | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// v3-P3-2: 批量选择与删除
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false)
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
setIsDeleting(true)
|
||||
const result = await deleteGradeRecordAction(deleteId)
|
||||
setIsDeleting(false)
|
||||
if (result.success) {
|
||||
const result = await safeActionCall(
|
||||
() => deleteGradeRecordAction(deleteId),
|
||||
{
|
||||
onError: () => toast.error(t("error.deleteFailed")),
|
||||
onFinally: () => setIsDeleting(false),
|
||||
}
|
||||
)
|
||||
if (result?.success) {
|
||||
toast.success(result.message)
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else if (result) {
|
||||
toast.error(result.message || t("error.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
// v3-P3-2: 切换单行选择
|
||||
const toggleRowSelection = (id: string): void => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
toast.error(result.message || "Failed to delete")
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// v3-P3-2: 全选/取消全选
|
||||
const toggleSelectAll = (): void => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === records.length) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(records.map((r) => r.id))
|
||||
})
|
||||
}
|
||||
|
||||
// v3-P3-2: 清空选择
|
||||
const clearSelection = (): void => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
// v3-P3-2: 批量删除
|
||||
const handleBulkDelete = async (): Promise<void> => {
|
||||
if (selectedIds.size === 0) return
|
||||
setIsBulkDeleting(true)
|
||||
const ids = Array.from(selectedIds)
|
||||
const result = await safeActionCall(
|
||||
() => bulkDeleteGradeRecordsAction(ids),
|
||||
{
|
||||
onError: () => toast.error(t("list.bulkDeleteFailed")),
|
||||
onFinally: () => setIsBulkDeleting(false),
|
||||
}
|
||||
)
|
||||
if (result?.success) {
|
||||
toast.success(t("list.bulkDeleteSuccess", { count: result.data ?? 0 }))
|
||||
clearSelection()
|
||||
setIsBulkDeleteDialogOpen(false)
|
||||
router.refresh()
|
||||
} else if (result) {
|
||||
toast.error(result.message || t("list.bulkDeleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (r: GradeRecordListItem): void => {
|
||||
setEditTarget({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
score: String(r.score),
|
||||
fullScore: String(r.fullScore),
|
||||
type: r.type,
|
||||
semester: r.semester,
|
||||
remark: r.remark ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditSave = async (): Promise<void> => {
|
||||
if (!editTarget) return
|
||||
setIsSaving(true)
|
||||
const formData = new FormData()
|
||||
formData.set("title", editTarget.title)
|
||||
formData.set("score", editTarget.score)
|
||||
formData.set("fullScore", editTarget.fullScore)
|
||||
formData.set("type", editTarget.type)
|
||||
formData.set("semester", editTarget.semester)
|
||||
formData.set("remark", editTarget.remark)
|
||||
|
||||
const result = await safeActionCall(
|
||||
() => updateGradeRecordAction(editTarget.id, null, formData),
|
||||
{
|
||||
onError: () => toast.error(t("edit.failed")),
|
||||
onFinally: () => setIsSaving(false),
|
||||
}
|
||||
)
|
||||
if (result?.success) {
|
||||
toast.success(t("edit.success"))
|
||||
setEditTarget(null)
|
||||
router.refresh()
|
||||
} else if (result) {
|
||||
toast.error(result.message || t("edit.failed"))
|
||||
}
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
|
||||
No grade records found.
|
||||
{t("list.empty")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -58,31 +184,75 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border bg-card">
|
||||
{/* v3-P3-2: 批量操作工具栏 */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-4 py-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("list.bulkDeleteSelected", { count: selectedIds.size })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
{t("list.clearSelection")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("list.bulkDelete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* v4-P1-10: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<caption className="sr-only">成绩记录列表</caption>
|
||||
<caption className="sr-only">{t("list.caption")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="text-right">Score</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Semester</TableHead>
|
||||
<TableHead>Recorded By</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={records.length > 0 && selectedIds.size === records.length}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label={t("list.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t("list.columns.student")}</TableHead>
|
||||
<TableHead>{t("list.columns.class")}</TableHead>
|
||||
<TableHead>{t("list.columns.subject")}</TableHead>
|
||||
<TableHead>{t("list.columns.title")}</TableHead>
|
||||
<TableHead className="text-right">{t("list.columns.score")}</TableHead>
|
||||
<TableHead>{t("list.columns.type")}</TableHead>
|
||||
<TableHead>{t("list.columns.semester")}</TableHead>
|
||||
<TableHead>{t("list.columns.recordedBy")}</TableHead>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
<TableHead className="w-24">{t("list.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(r.id)}
|
||||
onCheckedChange={() => toggleRowSelection(r.id)}
|
||||
aria-label={t("list.selectRow", { name: r.studentName })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{r.studentName}</TableCell>
|
||||
<TableCell>{r.className}</TableCell>
|
||||
<TableCell>{r.subjectName}</TableCell>
|
||||
<TableCell>{r.title}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{r.score} / {r.fullScore}
|
||||
<TableCell className="text-right">
|
||||
{/* v4-P1-7: 成绩颜色编码 */}
|
||||
<ScoreCell score={r.score} fullScore={r.fullScore} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
|
||||
@@ -91,36 +261,167 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => startEdit(r)}
|
||||
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={`删除 ${r.studentName} 的 ${r.subjectName} 成绩记录`}
|
||||
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<Dialog open={editTarget !== null} onOpenChange={(open) => !open && setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("edit.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget?.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editTarget && (
|
||||
<div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2">
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="edit-title">{t("edit.titleLabel")}</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={editTarget.title}
|
||||
onChange={(e) => setEditTarget({ ...editTarget, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-score">{t("edit.score")}</Label>
|
||||
<Input
|
||||
id="edit-score"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editTarget.score}
|
||||
onChange={(e) => setEditTarget({ ...editTarget, score: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-fullScore">{t("edit.fullScore")}</Label>
|
||||
<Input
|
||||
id="edit-fullScore"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="1"
|
||||
value={editTarget.fullScore}
|
||||
onChange={(e) => setEditTarget({ ...editTarget, fullScore: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-type">{t("filters.type")}</Label>
|
||||
<Select
|
||||
value={editTarget.type}
|
||||
onValueChange={(v) => { if (isGradeType(v)) setEditTarget({ ...editTarget, type: v }) }}
|
||||
>
|
||||
<SelectTrigger id="edit-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exam">{t("type.exam")}</SelectItem>
|
||||
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
|
||||
<SelectItem value="homework">{t("type.homework")}</SelectItem>
|
||||
<SelectItem value="other">{t("type.other")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-semester">{t("filters.semester")}</Label>
|
||||
<Select
|
||||
value={editTarget.semester}
|
||||
onValueChange={(v) => { if (isSemester(v)) setEditTarget({ ...editTarget, semester: v }) }}
|
||||
>
|
||||
<SelectTrigger id="edit-semester">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">{t("semester.s1")}</SelectItem>
|
||||
<SelectItem value="2">{t("semester.s2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="edit-remark">{t("edit.remark")}</Label>
|
||||
<Textarea
|
||||
id="edit-remark"
|
||||
placeholder={t("edit.remarkPlaceholder")}
|
||||
value={editTarget.remark}
|
||||
onChange={(e) => setEditTarget({ ...editTarget, remark: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)} disabled={isSaving}>
|
||||
{t("edit.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={isSaving}>
|
||||
{isSaving ? t("edit.saving") : t("edit.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Grade Record</DialogTitle>
|
||||
<DialogTitle>{t("delete.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this grade record? This action cannot be undone.
|
||||
{t("delete.confirmation")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
||||
Cancel
|
||||
{t("delete.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
{isDeleting ? t("delete.deleting") : t("delete.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* v3-P3-2: 批量删除确认对话框 */}
|
||||
<Dialog open={isBulkDeleteDialogOpen} onOpenChange={(open) => !open && setIsBulkDeleteDialogOpen(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("list.bulkDelete")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("list.bulkDeleteConfirmation", { count: selectedIds.size })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(false)}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
{t("delete.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleBulkDelete} disabled={isBulkDeleting}>
|
||||
{isBulkDeleting ? t("delete.deleting") : t("delete.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { StatItem } from "@/shared/components/ui/stat-item"
|
||||
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
|
||||
import type { GradeStats } from "../types"
|
||||
|
||||
export function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
|
||||
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!stats || stats.count === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistics</CardTitle>
|
||||
<CardTitle>{t("stats.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">No data available for statistics.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("stats.noData")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -20,50 +24,50 @@ export function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistics</CardTitle>
|
||||
<CardTitle>{t("stats.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatItem
|
||||
label="Average"
|
||||
label={t("stats.average")}
|
||||
value={stats.average.toFixed(2)}
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Median"
|
||||
label={t("stats.median")}
|
||||
value={stats.median.toFixed(2)}
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Max"
|
||||
label={t("stats.max")}
|
||||
value={stats.max.toFixed(2)}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Min"
|
||||
label={t("stats.min")}
|
||||
value={stats.min.toFixed(2)}
|
||||
icon={<TrendingDown className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label="Std Dev"
|
||||
label={t("stats.stdDev")}
|
||||
value={stats.stdDev.toFixed(2)}
|
||||
icon={<Target className="h-4 w-4" />}
|
||||
hint="Standard deviation"
|
||||
hint={t("stats.stdDevHint")}
|
||||
/>
|
||||
<StatItem
|
||||
label="Pass Rate"
|
||||
label={t("stats.passRate")}
|
||||
value={`${stats.passRate.toFixed(1)}%`}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
hint="Score >= 60% of full"
|
||||
hint={t("stats.passRateHint")}
|
||||
/>
|
||||
<StatItem
|
||||
label="Excellent Rate"
|
||||
label={t("stats.excellentRate")}
|
||||
value={`${stats.excellentRate.toFixed(1)}%`}
|
||||
icon={<Award className="h-4 w-4" />}
|
||||
hint="Score >= 85% of full"
|
||||
hint={t("stats.excellentRateHint")}
|
||||
/>
|
||||
<StatItem
|
||||
label="Count"
|
||||
label={t("stats.count")}
|
||||
value={stats.count}
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
/>
|
||||
|
||||
@@ -1,51 +1,169 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { ClassAverageTrendResult } from "../types"
|
||||
import type { StudentGradeSummary } from "../types"
|
||||
|
||||
export function GradeTrendCard({ summary }: { summary: StudentGradeSummary }) {
|
||||
const chartData = useMemo(() => {
|
||||
return [...summary.records]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
interface GradeTrendCardProps {
|
||||
summary: StudentGradeSummary
|
||||
/** v3-P2-2: 班级平均趋势数据,传入后会在趋势图中叠加对比线 */
|
||||
classAverageData?: ClassAverageTrendResult | null
|
||||
}
|
||||
|
||||
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps) {
|
||||
const t = useTranslations("grades")
|
||||
// v3-P3-4: 日期范围选择器,通过 URL 参数持久化
|
||||
const [trendRange, setTrendRange] = useQueryState(
|
||||
"trendRange",
|
||||
parseAsString.withDefault("all"),
|
||||
)
|
||||
|
||||
// v3-P3-4: 在 useEffect 中计算日期截止时间,避免在渲染阶段调用 Date.now()
|
||||
const [cutoff, setCutoff] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
const days = trendRange === "all" ? null : Number(trendRange)
|
||||
const next =
|
||||
days !== null && Number.isFinite(days) && days > 0
|
||||
? Date.now() - days * 24 * 60 * 60 * 1000
|
||||
: null
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- 日期范围变更时需重新计算截止时间戳
|
||||
setCutoff(next)
|
||||
}, [trendRange])
|
||||
|
||||
const { chartData, hasClassAverage } = useMemo(() => {
|
||||
// v3-P3-4: 根据日期范围筛选记录
|
||||
const records =
|
||||
cutoff !== null
|
||||
? summary.records.filter((r) => {
|
||||
const time = new Date(r.createdAt).getTime()
|
||||
return !Number.isNaN(time) && time >= cutoff
|
||||
})
|
||||
: summary.records
|
||||
|
||||
// 学生个人趋势:按时间升序
|
||||
const studentPoints = [...records]
|
||||
.sort((a, b) => {
|
||||
const timeA = new Date(a.createdAt).getTime()
|
||||
const timeB = new Date(b.createdAt).getTime()
|
||||
if (Number.isNaN(timeA) && Number.isNaN(timeB)) return 0
|
||||
if (Number.isNaN(timeA)) return 1
|
||||
if (Number.isNaN(timeB)) return -1
|
||||
return timeA - timeB
|
||||
})
|
||||
.map((r) => ({
|
||||
title: r.title,
|
||||
score: Math.round((r.score / r.fullScore) * 100),
|
||||
// P3 修复:添加 fullScore > 0 校验,避免除零
|
||||
score: r.fullScore > 0 ? Math.round((r.score / r.fullScore) * 100) : 0,
|
||||
fullTitle: r.title,
|
||||
submittedAt: formatDate(r.createdAt),
|
||||
rawScore: r.score,
|
||||
maxScore: r.fullScore,
|
||||
}))
|
||||
}, [summary.records])
|
||||
|
||||
// v3-P2-2: 合并班级平均数据(按 title 对齐)
|
||||
const classAvgByTitle = new Map<string, number>()
|
||||
if (classAverageData?.points?.length) {
|
||||
for (const p of classAverageData.points) {
|
||||
classAvgByTitle.set(p.title, p.averageScore)
|
||||
}
|
||||
}
|
||||
|
||||
// 将班级平均分合并到 chartData(缺失的 title 不显示对比点)
|
||||
const merged = studentPoints.map((p) => ({
|
||||
...p,
|
||||
classAverage: classAvgByTitle.has(p.title)
|
||||
? classAvgByTitle.get(p.title)
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
return {
|
||||
chartData: merged,
|
||||
hasClassAverage: classAvgByTitle.size > 0,
|
||||
}
|
||||
}, [summary.records, classAverageData, cutoff])
|
||||
|
||||
const hasData = chartData.length > 0
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Grade Trend"
|
||||
icon={BarChart3}
|
||||
iconClassName="text-muted-foreground"
|
||||
isEmpty={!hasData}
|
||||
emptyTitle="No grade records yet"
|
||||
emptyDescription="Your grade trend will appear here once records are added."
|
||||
emptyClassName="h-64"
|
||||
>
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
// v3-P2-2: 动态构建 series(有班级平均时叠加第二条线)
|
||||
const series = hasClassAverage
|
||||
? [
|
||||
{
|
||||
dataKey: "score",
|
||||
name: "Score (%)",
|
||||
name: t("chart.scorePercent"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
},
|
||||
]}
|
||||
{
|
||||
dataKey: "classAverage",
|
||||
name: t("chart.classAverage"),
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
dataKey: "score",
|
||||
name: t("chart.scorePercent"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title={t("analytics.trend")}
|
||||
icon={BarChart3}
|
||||
iconClassName="text-muted-foreground"
|
||||
isEmpty={!hasData}
|
||||
emptyTitle={t("trend.empty")}
|
||||
emptyDescription={t("trendCard.emptyDescription")}
|
||||
emptyClassName="h-64"
|
||||
action={
|
||||
<Select value={trendRange} onValueChange={setTrendRange}>
|
||||
<SelectTrigger
|
||||
className="w-32"
|
||||
aria-label={t("trendCard.rangeAriaLabel")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("trendRange.all")}</SelectItem>
|
||||
<SelectItem value="7">{t("trendRange.days7")}</SelectItem>
|
||||
<SelectItem value="30">{t("trendRange.days30")}</SelectItem>
|
||||
<SelectItem value="90">{t("trendRange.days90")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-md border bg-card p-4"
|
||||
role="img"
|
||||
aria-label={
|
||||
hasData
|
||||
? t("trendCard.ariaLabelNonEmpty", { count: chartData.length })
|
||||
: t("trendCard.ariaLabelEmpty")
|
||||
}
|
||||
>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={series}
|
||||
heightClassName="h-[240px]"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={30}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
@@ -12,6 +13,7 @@ interface GradeTrendChartProps {
|
||||
}
|
||||
|
||||
export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
const t = useTranslations("grades")
|
||||
const isEmpty = !data || data.points.length === 0
|
||||
|
||||
const chartData = isEmpty
|
||||
@@ -28,25 +30,25 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Grade Trend"
|
||||
title={t("analytics.trend")}
|
||||
description={
|
||||
isEmpty
|
||||
? "Score progression over time (normalized to 0-100)."
|
||||
: `${data.label} · avg ${data.averageScore.toFixed(1)}%`
|
||||
? t("trendChart.descriptionEmpty")
|
||||
: t("trendChart.descriptionNonEmpty", { label: data.label, average: data.averageScore.toFixed(1) })
|
||||
}
|
||||
icon={BarChart3}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No trend data"
|
||||
emptyDescription="Select a class and subject to view the grade trend."
|
||||
emptyTitle={t("trendChart.emptyTitle")}
|
||||
emptyDescription={t("trendChart.emptyDescription")}
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<div role="img" aria-label={`成绩趋势图:${isEmpty ? "暂无数据" : `${data.label},平均 ${data.averageScore.toFixed(1)}%`}`}>
|
||||
<div role="img" aria-label={isEmpty ? t("trendChart.ariaLabelEmpty") : t("trendChart.ariaLabelNonEmpty", { label: data.label, average: data.averageScore.toFixed(1) })}>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "normalizedScore",
|
||||
name: "Score (%)",
|
||||
name: t("chart.scorePercent"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
|
||||
78
src/modules/grades/components/ranking-trend-card.tsx
Normal file
78
src/modules/grades/components/ranking-trend-card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { RankingTrendResult } from "../types"
|
||||
|
||||
/**
|
||||
* 排名趋势图组件。
|
||||
*
|
||||
* v3-P1-3:学生页面显示排名趋势,Y 轴反转(第 1 名在顶部)。
|
||||
* 对比同类 K12 系统(PowerSchool、Infinite Campus 等)的排名趋势功能。
|
||||
*/
|
||||
export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }) {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!trend || trend.points.length === 0) return []
|
||||
return trend.points.map((p) => ({
|
||||
title: p.title,
|
||||
rank: p.rank,
|
||||
totalStudents: p.totalStudents,
|
||||
fullTitle: p.title,
|
||||
submittedAt: formatDate(p.date),
|
||||
}))
|
||||
}, [trend])
|
||||
|
||||
const hasData = chartData.length > 0
|
||||
const maxRank = useMemo(() => {
|
||||
if (chartData.length === 0) return 1
|
||||
return Math.max(...chartData.map((d) => d.rank), 1)
|
||||
}, [chartData])
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title={t("analytics.rankingTrend")}
|
||||
icon={BarChart3}
|
||||
iconClassName="text-muted-foreground"
|
||||
isEmpty={!hasData}
|
||||
emptyTitle={t("trend.empty")}
|
||||
emptyDescription={t("trendCard.emptyDescription")}
|
||||
emptyClassName="h-64"
|
||||
>
|
||||
<div
|
||||
className="rounded-md border bg-card p-4"
|
||||
role="img"
|
||||
aria-label={
|
||||
hasData
|
||||
? t("rankingTrend.ariaLabelNonEmpty", { count: chartData.length })
|
||||
: t("rankingTrend.ariaLabelEmpty")
|
||||
}
|
||||
>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "rank",
|
||||
name: t("analytics.ranking"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
},
|
||||
]}
|
||||
yDomain={[Math.max(maxRank, 1), 1]}
|
||||
yTickFormatter={(value: number) => `#${value}`}
|
||||
heightClassName="h-[240px]"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={40}
|
||||
tooltipClassName="w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
131
src/modules/grades/components/school-wide-summary-card.tsx
Normal file
131
src/modules/grades/components/school-wide-summary-card.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { JSX } from "react"
|
||||
import { School, TrendingUp, CheckCircle2, Award } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { formatNumber } from "@/shared/lib/utils"
|
||||
import type { SchoolWideGradeSummary } from "../types"
|
||||
|
||||
interface SchoolWideSummaryCardProps {
|
||||
summary: SchoolWideGradeSummary
|
||||
}
|
||||
|
||||
export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps): JSX.Element {
|
||||
const { totals, grades } = summary
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 全校汇总统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">年级数</CardTitle>
|
||||
<School className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{totals.gradeCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totals.classCount} 个班级 · {totals.studentCount} 名学生
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">总体均分</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{formatNumber(totals.averageScore)}</div>
|
||||
<p className="text-xs text-muted-foreground">基于 {totals.recordCount} 条成绩记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">及格率</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums text-green-600">
|
||||
{formatNumber(totals.passRate)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">及格线 60%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">优秀率</CardTitle>
|
||||
<Award className="h-4 w-4 text-amber-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums text-amber-600">
|
||||
{formatNumber(totals.excellentRate)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">优秀线 85%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 各年级对比表格 */}
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">各年级成绩对比</CardTitle>
|
||||
<CardDescription>按年级聚合的平均分、及格率与优秀率对比。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{grades.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">暂无年级成绩数据</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>学校 / 年级</TableHead>
|
||||
<TableHead className="text-right">班级数</TableHead>
|
||||
<TableHead className="text-right">学生数</TableHead>
|
||||
<TableHead className="text-right">记录数</TableHead>
|
||||
<TableHead className="text-right">平均分</TableHead>
|
||||
<TableHead className="text-right">及格率</TableHead>
|
||||
<TableHead className="text-right">优秀率</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grades.map((g) => (
|
||||
<TableRow key={g.gradeId}>
|
||||
<TableCell className="font-medium">
|
||||
{g.schoolName}
|
||||
<span className="text-muted-foreground"> / {g.gradeName}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{g.classCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{g.studentCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{g.recordCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
{formatNumber(g.averageScore)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<Badge
|
||||
variant={g.passRate >= 80 ? "default" : g.passRate >= 60 ? "secondary" : "destructive"}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{formatNumber(g.passRate)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<Badge
|
||||
variant={g.excellentRate >= 30 ? "default" : "outline"}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{formatNumber(g.excellentRate)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/modules/grades/components/score-cell.tsx
Normal file
41
src/modules/grades/components/score-cell.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* v4-P1-7: 成绩单元格组件,根据得分率着色。
|
||||
*
|
||||
* 着色规则(对标 PowerSchool / Canvas / 超星学习通):
|
||||
* - 得分率 < 60%:红色(不及格)
|
||||
* - 得分率 60% ~ 84%:黄色(及格但未达优秀)
|
||||
* - 得分率 ≥ 85%:绿色(优秀)
|
||||
* - fullScore <= 0:不着色(异常数据,避免除零)
|
||||
*
|
||||
* 使用语义化的 Tailwind 类名,避免动态拼接。
|
||||
*/
|
||||
export function ScoreCell({
|
||||
score,
|
||||
fullScore,
|
||||
className,
|
||||
}: {
|
||||
score: number
|
||||
fullScore: number
|
||||
className?: string
|
||||
}): JSX.Element {
|
||||
const ratio = fullScore > 0 ? score / fullScore : 1
|
||||
const isFail = ratio < 0.6
|
||||
const isExcellent = ratio >= 0.85
|
||||
|
||||
const colorClass = isFail
|
||||
? "text-red-600 font-semibold"
|
||||
: isExcellent
|
||||
? "text-green-600 font-semibold"
|
||||
: "text-yellow-600 font-medium"
|
||||
|
||||
return (
|
||||
<span className={cn("font-mono tabular-nums", colorClass, className)}>
|
||||
{score} / {fullScore}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
|
||||
@@ -9,12 +10,13 @@ interface StatsClassSelectorProps {
|
||||
currentSubjectId: string
|
||||
}
|
||||
|
||||
export function StatsClassSelector({
|
||||
export async function StatsClassSelector({
|
||||
classes,
|
||||
subjects,
|
||||
currentClassId,
|
||||
currentSubjectId,
|
||||
}: StatsClassSelectorProps): JSX.Element {
|
||||
}: StatsClassSelectorProps): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ChipNav
|
||||
@@ -33,7 +35,7 @@ export function StatsClassSelector({
|
||||
? `/teacher/grades/stats?classId=${currentClassId}`
|
||||
: `/teacher/grades/stats?classId=${currentClassId}&subjectId=${id}`
|
||||
}
|
||||
allOption={{ id: "all", label: "All Subjects" }}
|
||||
allOption={{ id: "all", label: t("filters.allSubjects") }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -10,23 +11,20 @@ import {
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { GraduationCap, Trophy } from "lucide-react"
|
||||
|
||||
import type { StudentGradeSummary } from "../types"
|
||||
import { GRADE_TYPE_VARIANT } from "../types"
|
||||
|
||||
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
exam: "default",
|
||||
quiz: "secondary",
|
||||
homework: "outline",
|
||||
other: "outline",
|
||||
}
|
||||
export async function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
|
||||
if (!summary) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No data"
|
||||
description="Student grade summary is not available."
|
||||
title={t("summary.noDataTitle")}
|
||||
description={t("summary.noDataDescription")}
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -35,10 +33,10 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.student")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||
@@ -46,7 +44,7 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Average Score</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.averageScore")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageScore.toFixed(2)}</p>
|
||||
@@ -54,7 +52,24 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.classRank")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">
|
||||
{summary.rank > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Trophy className="h-5 w-5 text-amber-500" aria-hidden="true" />
|
||||
{t("summary.rankValue", { rank: summary.rank })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.totalRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.records.length}</p>
|
||||
@@ -64,28 +79,29 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
|
||||
|
||||
{summary.records.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No grades yet"
|
||||
description="There are no grade records for this student."
|
||||
title={t("summary.noGradesTitle")}
|
||||
description={t("summary.noGradesDescription")}
|
||||
icon={GraduationCap}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grade History</CardTitle>
|
||||
<CardTitle>{t("summary.gradeHistory")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<caption className="sr-only">{t("summary.caption")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead className="text-right">Score</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Semester</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>{t("list.columns.title")}</TableHead>
|
||||
<TableHead>{t("list.columns.class")}</TableHead>
|
||||
<TableHead>{t("list.columns.subject")}</TableHead>
|
||||
<TableHead className="text-right">{t("list.columns.score")}</TableHead>
|
||||
<TableHead>{t("list.columns.type")}</TableHead>
|
||||
<TableHead>{t("list.columns.semester")}</TableHead>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -98,9 +114,7 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
|
||||
{r.score} / {r.fullScore}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={typeColors[r.type]} className="capitalize">
|
||||
{r.type}
|
||||
</Badge>
|
||||
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
|
||||
</TableCell>
|
||||
<TableCell>S{r.semester}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Radar } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { ComparisonRadarChart } from "@/shared/components/charts/comparison-radar-chart"
|
||||
@@ -11,6 +12,7 @@ interface SubjectComparisonChartProps {
|
||||
}
|
||||
|
||||
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
const t = useTranslations("grades")
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
const chartData = isEmpty
|
||||
@@ -25,19 +27,19 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Subject Comparison"
|
||||
title={t("analytics.subjectComparison")}
|
||||
description={
|
||||
isEmpty
|
||||
? "Compare performance across subjects for the selected class."
|
||||
: "Average score and pass rate per subject (normalized to 0-100)."
|
||||
? t("subjectComparison.descriptionEmpty")
|
||||
: t("subjectComparison.descriptionNonEmpty")
|
||||
}
|
||||
icon={Radar}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No comparison data"
|
||||
emptyDescription="Select a class to compare subject performance."
|
||||
emptyTitle={t("subjectComparison.emptyTitle")}
|
||||
emptyDescription={t("subjectComparison.emptyDescription")}
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<div role="img" aria-label={`科目对比雷达图:${isEmpty ? "暂无数据" : `共 ${data.length} 个科目的均分与及格率对比`}`}>
|
||||
<div role="img" aria-label={isEmpty ? t("subjectComparison.ariaLabelEmpty") : t("subjectComparison.ariaLabelNonEmpty", { count: data.length })}>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="subject"
|
||||
@@ -48,13 +50,13 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: "Average",
|
||||
name: t("chart.average"),
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.4,
|
||||
},
|
||||
{
|
||||
dataKey: "passRate",
|
||||
name: "Pass Rate",
|
||||
name: t("chart.passRate"),
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { Component, Suspense, type ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
@@ -37,14 +38,20 @@ interface WidgetBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
interface WidgetErrorBoundaryProps {
|
||||
title: string
|
||||
fallbackDescription: string
|
||||
retryLabel: string
|
||||
loadFailedMessage: string
|
||||
retryAriaLabel: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
class WidgetErrorBoundary extends Component<
|
||||
Pick<
|
||||
WidgetBoundaryProps,
|
||||
"title" | "fallbackDescription" | "retryLabel" | "children"
|
||||
>,
|
||||
WidgetErrorBoundaryProps,
|
||||
WidgetBoundaryState
|
||||
> {
|
||||
constructor(props: WidgetBoundaryProps) {
|
||||
constructor(props: WidgetErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
@@ -59,7 +66,6 @@ class WidgetErrorBoundary extends Component<
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const title = this.props.title ?? "区块"
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -69,19 +75,19 @@ class WidgetErrorBoundary extends Component<
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{title}加载失败
|
||||
{this.props.loadFailedMessage}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.fallbackDescription ?? "请重试或刷新页面"}
|
||||
{this.props.fallbackDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
aria-label={`重试加载${title}`}
|
||||
aria-label={this.props.retryAriaLabel}
|
||||
>
|
||||
{this.props.retryLabel ?? "重试"}
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -93,15 +99,15 @@ class WidgetErrorBoundary extends Component<
|
||||
|
||||
function WidgetSkeleton({
|
||||
height,
|
||||
title,
|
||||
loadingAriaLabel,
|
||||
}: {
|
||||
height: number
|
||||
title?: string
|
||||
loadingAriaLabel: string
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={`${title ?? "区块"}加载中`}
|
||||
aria-label={loadingAriaLabel}
|
||||
aria-live="polite"
|
||||
className="space-y-3 p-4"
|
||||
style={{ minHeight: height }}
|
||||
@@ -121,15 +127,25 @@ export function WidgetBoundary({
|
||||
fallbackDescription,
|
||||
retryLabel,
|
||||
}: WidgetBoundaryProps): ReactNode {
|
||||
const t = useTranslations("grades")
|
||||
const effectiveTitle = title ?? t("widget.block")
|
||||
const effectiveFallbackDescription = fallbackDescription ?? t("widget.defaultFallback")
|
||||
const effectiveRetryLabel = retryLabel ?? t("widget.retry")
|
||||
const loadFailedMessage = t("widget.loadFailed", { title: effectiveTitle })
|
||||
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
|
||||
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
|
||||
|
||||
return (
|
||||
<WidgetErrorBoundary
|
||||
title={title}
|
||||
fallbackDescription={fallbackDescription}
|
||||
retryLabel={retryLabel}
|
||||
title={effectiveTitle}
|
||||
fallbackDescription={effectiveFallbackDescription}
|
||||
retryLabel={effectiveRetryLabel}
|
||||
loadFailedMessage={loadFailedMessage}
|
||||
retryAriaLabel={retryAriaLabel}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<WidgetSkeleton height={skeletonHeight} title={title} />
|
||||
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray, isNotNull, ne } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
@@ -9,14 +9,16 @@ import {
|
||||
getClassesByGradeId,
|
||||
getClassNameById,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getGrades, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { buildScopeClassFilter, normalize, toNumber } from "./lib/grade-utils"
|
||||
import { normalize, toNumber } from "./lib/grade-utils"
|
||||
import { buildScopeClassFilter } from "./lib/scope-filter"
|
||||
import {
|
||||
buildGradeTrendPoints,
|
||||
computeClassComparisonStats,
|
||||
computeGradeDistribution,
|
||||
computeGradeStats,
|
||||
computeSubjectComparisonStats,
|
||||
computeTrendAverage,
|
||||
} from "./stats-service"
|
||||
@@ -24,6 +26,8 @@ import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionResult,
|
||||
GradeTrendResult,
|
||||
SchoolWideGradeSummary,
|
||||
SchoolWideGradeSummaryItem,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
@@ -32,6 +36,7 @@ export interface GradeTrendParams {
|
||||
subjectId?: string
|
||||
studentId?: string
|
||||
semester?: "1" | "2"
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}
|
||||
@@ -42,6 +47,7 @@ export const getGradeTrend = cache(
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
@@ -90,6 +96,7 @@ export interface ClassComparisonParams {
|
||||
gradeId: string
|
||||
subjectId: string
|
||||
examId?: string
|
||||
semester?: "1" | "2"
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
@@ -110,11 +117,16 @@ export const getClassComparison = cache(
|
||||
|
||||
const allowedClassIds = allowedClassRows.map((c) => c.id)
|
||||
|
||||
// P3 修复:在 SQL where 中使用 inArray 过滤,并通过 buildScopeClassFilter 应用 scope 行级过滤
|
||||
const conditions = [
|
||||
inArray(gradeRecords.classId, allowedClassIds),
|
||||
eq(gradeRecords.subjectId, params.subjectId),
|
||||
]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const allRows = await db
|
||||
.select({
|
||||
@@ -153,6 +165,7 @@ export const getClassComparison = cache(
|
||||
export interface SubjectComparisonParams {
|
||||
classId: string
|
||||
examId?: string
|
||||
semester?: "1" | "2"
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
@@ -161,6 +174,7 @@ export const getSubjectComparison = cache(
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
@@ -208,6 +222,7 @@ export interface GradeDistributionParams {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
semester?: "1" | "2"
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}
|
||||
@@ -217,6 +232,7 @@ export const getGradeDistribution = cache(
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
@@ -233,3 +249,145 @@ export const getGradeDistribution = cache(
|
||||
return computeGradeDistribution(rows)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* v3-P2-7: 获取指定班级/科目下有成绩记录的考试列表,用于分析页考试筛选下拉框。
|
||||
* 仅返回 examId 非空且去重后的考试选项。
|
||||
*/
|
||||
export const getExamOptionsForGrades = cache(
|
||||
async (params: {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
scope: DataScope
|
||||
}): Promise<Array<{ id: string; title: string }>> => {
|
||||
const conditions = [
|
||||
eq(gradeRecords.classId, params.classId),
|
||||
isNotNull(gradeRecords.examId),
|
||||
ne(gradeRecords.examId, ""),
|
||||
]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
examId: gradeRecords.examId,
|
||||
title: gradeRecords.title,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
// 去重:同一 examId 可能有多条成绩记录,保留第一条的 title
|
||||
const seen = new Set<string>()
|
||||
const result: Array<{ id: string; title: string }> = []
|
||||
for (const r of rows) {
|
||||
if (!r.examId || seen.has(r.examId)) continue
|
||||
seen.add(r.examId)
|
||||
result.push({ id: r.examId, title: r.title })
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* v3-P2-9: 获取全校各年级成绩汇总(管理员视图)。
|
||||
* 按年级聚合平均分、及格率、优秀率、学生数、班级数。
|
||||
* 仅限管理员(scope.type === "all")调用。
|
||||
*/
|
||||
export const getSchoolWideGradeSummary = cache(
|
||||
async (scope: DataScope): Promise<SchoolWideGradeSummary> => {
|
||||
// 管理员权限校验:仅 all scope 可调用
|
||||
if (scope.type !== "all") {
|
||||
return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } }
|
||||
}
|
||||
|
||||
const allGrades = await getGrades()
|
||||
if (allGrades.length === 0) {
|
||||
return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } }
|
||||
}
|
||||
|
||||
// 并行查询每个年级的班级列表
|
||||
const gradeClassMaps = await Promise.all(
|
||||
allGrades.map(async (g) => ({
|
||||
grade: g,
|
||||
classList: await getClassesByGradeId(g.id),
|
||||
})),
|
||||
)
|
||||
|
||||
// 过滤掉无班级的年级
|
||||
const validGradeClassMaps = gradeClassMaps.filter((m) => m.classList.length > 0)
|
||||
if (validGradeClassMaps.length === 0) {
|
||||
return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } }
|
||||
}
|
||||
|
||||
// 并行查询每个年级的成绩记录(通过 classId inArray 过滤)
|
||||
const gradeStatsResults = await Promise.all(
|
||||
validGradeClassMaps.map(async (m) => {
|
||||
const classIds = m.classList.map((c) => c.id)
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
studentId: gradeRecords.studentId,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(inArray(gradeRecords.classId, classIds))
|
||||
|
||||
const stats = computeGradeStats(rows)
|
||||
const uniqueStudents = new Set(rows.map((r) => r.studentId))
|
||||
|
||||
return {
|
||||
grade: m.grade,
|
||||
classCount: m.classList.length,
|
||||
studentCount: uniqueStudents.size,
|
||||
recordCount: rows.length,
|
||||
stats,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const gradeItems: SchoolWideGradeSummaryItem[] = gradeStatsResults
|
||||
.filter((r) => r.stats !== null)
|
||||
.map((r) => ({
|
||||
gradeId: r.grade.id,
|
||||
gradeName: r.grade.name,
|
||||
schoolName: r.grade.school.name,
|
||||
classCount: r.classCount,
|
||||
studentCount: r.studentCount,
|
||||
recordCount: r.recordCount,
|
||||
averageScore: r.stats!.average,
|
||||
passRate: r.stats!.passRate,
|
||||
excellentRate: r.stats!.excellentRate,
|
||||
}))
|
||||
.sort((a, b) => a.schoolName.localeCompare(b.schoolName) || a.gradeName.localeCompare(b.gradeName))
|
||||
|
||||
// 计算全校汇总(按记录数加权平均)
|
||||
const totalClassCount = gradeItems.reduce((sum, g) => sum + g.classCount, 0)
|
||||
const totalStudentCount = gradeItems.reduce((sum, g) => sum + g.studentCount, 0)
|
||||
const totalRecordCount = gradeItems.reduce((sum, g) => sum + g.recordCount, 0)
|
||||
|
||||
const weightedAvg = totalRecordCount > 0
|
||||
? Math.round((gradeItems.reduce((sum, g) => sum + g.averageScore * g.recordCount, 0) / totalRecordCount) * 100) / 100
|
||||
: 0
|
||||
const weightedPassRate = totalRecordCount > 0
|
||||
? Math.round((gradeItems.reduce((sum, g) => sum + g.passRate * g.recordCount, 0) / totalRecordCount) * 100) / 100
|
||||
: 0
|
||||
const weightedExcellentRate = totalRecordCount > 0
|
||||
? Math.round((gradeItems.reduce((sum, g) => sum + g.excellentRate * g.recordCount, 0) / totalRecordCount) * 100) / 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
grades: gradeItems,
|
||||
totals: {
|
||||
gradeCount: gradeItems.length,
|
||||
classCount: totalClassCount,
|
||||
studentCount: totalStudentCount,
|
||||
recordCount: totalRecordCount,
|
||||
averageScore: weightedAvg,
|
||||
passRate: weightedPassRate,
|
||||
excellentRate: weightedExcellentRate,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,24 +7,46 @@ import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { normalize, toNumber } from "./lib/grade-utils"
|
||||
import { buildScopeClassFilter } from "./lib/scope-filter"
|
||||
import { buildRankingTrendPoints, type RankingTrendEntry } from "./stats-service"
|
||||
import type {
|
||||
ClassAverageTrendPoint,
|
||||
ClassAverageTrendResult,
|
||||
RankingTrendResult,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* v3-P2-2: 班级平均成绩趋势点
|
||||
* 与 RankingTrendPoint 对齐(按 title 分组),用于在学生趋势图上叠加班级平均对比线。
|
||||
*/
|
||||
export type { ClassAverageTrendPoint, ClassAverageTrendResult }
|
||||
|
||||
/**
|
||||
* Get a student's ranking trend across assessments within their class.
|
||||
* Each point represents one assessment (grouped by title), with the
|
||||
* student's normalized score, rank, and total participants.
|
||||
*
|
||||
* P3 修复:添加 scope 参数,对 class_taught scope 校验学生归属
|
||||
*/
|
||||
export const getRankingTrend = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
subjectId?: string,
|
||||
semester?: "1" | "2"
|
||||
semester?: "1" | "2",
|
||||
scope?: DataScope
|
||||
): Promise<RankingTrendResult | null> => {
|
||||
// P3 修复:对 class_taught scope 校验学生是否属于教师所教的班级
|
||||
if (scope?.type === "class_taught") {
|
||||
const allowedClassIds = new Set(scope.classIds)
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
if (studentClassId && !allowedClassIds.has(studentClassId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const studentNameMap = await getUserNamesByIds([studentId])
|
||||
const studentInfo = studentNameMap.get(studentId)
|
||||
if (!studentInfo) return null
|
||||
@@ -44,6 +66,12 @@ export const getRankingTrend = cache(
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (semester) conditions.push(eq(gradeRecords.semester, semester))
|
||||
|
||||
// 应用 scope 过滤
|
||||
if (scope) {
|
||||
const scopeFilter = buildScopeClassFilter(scope, studentId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
title: gradeRecords.title,
|
||||
@@ -76,3 +104,87 @@ export const getRankingTrend = cache(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* v3-P2-2: 获取班级平均成绩趋势(按 assessment title 分组)。
|
||||
*
|
||||
* 用于在学生个人成绩趋势图上叠加"班级平均"对比线,让学生/家长能直观
|
||||
* 看到个人与班级整体的差距。与 `getRankingTrend` 共享相同的过滤条件
|
||||
* 与分组逻辑,确保两条线的 X 轴对齐。
|
||||
*
|
||||
* @param studentId 目标学生 ID(用于定位其所在班级)
|
||||
* @param subjectId 可选科目过滤
|
||||
* @param semester 可选学期过滤
|
||||
* @param scope 数据权限范围
|
||||
*/
|
||||
export const getClassAverageTrend = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
subjectId?: string,
|
||||
semester?: "1" | "2",
|
||||
scope?: DataScope
|
||||
): Promise<ClassAverageTrendResult | null> => {
|
||||
// 对 class_taught scope 校验学生归属
|
||||
if (scope?.type === "class_taught") {
|
||||
const allowedClassIds = new Set(scope.classIds)
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
if (studentClassId && !allowedClassIds.has(studentClassId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const classId = await getStudentActiveClassId(studentId)
|
||||
if (!classId) return null
|
||||
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (semester) conditions.push(eq(gradeRecords.semester, semester))
|
||||
|
||||
if (scope) {
|
||||
const scopeFilter = buildScopeClassFilter(scope, studentId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
title: gradeRecords.title,
|
||||
createdAt: gradeRecords.createdAt,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
// 按 title 分组,计算每次 assessment 的班级平均分(normalized 0-100)
|
||||
const byTitle = new Map<string, { date: Date; scores: number[] }>()
|
||||
for (const r of rows) {
|
||||
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
const entry = byTitle.get(r.title) ?? { date: r.createdAt, scores: [] }
|
||||
entry.scores.push(normalized)
|
||||
byTitle.set(r.title, entry)
|
||||
}
|
||||
|
||||
const points: ClassAverageTrendPoint[] = []
|
||||
for (const [title, entry] of byTitle.entries()) {
|
||||
if (entry.scores.length === 0) continue
|
||||
const sum = entry.scores.reduce((acc, s) => acc + s, 0)
|
||||
const avg = Math.round((sum / entry.scores.length) * 100) / 100
|
||||
points.push({
|
||||
title,
|
||||
date: entry.date.toISOString(),
|
||||
averageScore: avg,
|
||||
studentCount: entry.scores.length,
|
||||
})
|
||||
}
|
||||
|
||||
points.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
)
|
||||
|
||||
return {
|
||||
classId,
|
||||
points,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,21 +2,24 @@ import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, sql } from "drizzle-orm"
|
||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import { gradeDrafts, gradeRecords } from "@/shared/db/schema"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassExists,
|
||||
getClassNameById,
|
||||
getClassNamesByIds,
|
||||
getStudentActiveClassId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { NotFoundError } from "@/shared/lib/action-utils"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { buildScopeClassFilter, toNumber } from "./lib/grade-utils"
|
||||
import { toNumber } from "./lib/grade-utils"
|
||||
import { buildScopeClassFilter } from "./lib/scope-filter"
|
||||
import { computeAverageScore, computeGradeStats } from "./stats-service"
|
||||
import type {
|
||||
ClassGradeStats,
|
||||
@@ -33,6 +36,12 @@ import type {
|
||||
UpdateGradeRecordInput,
|
||||
} from "./schema"
|
||||
|
||||
/** 分页查询结果 */
|
||||
export interface PaginatedGradeRecords {
|
||||
records: GradeRecordListItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||
id: r.id,
|
||||
studentId: r.studentId,
|
||||
@@ -53,17 +62,18 @@ const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||
|
||||
export const getGradeRecords = cache(
|
||||
async (
|
||||
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
||||
): Promise<GradeRecordListItem[]> => {
|
||||
params: GradeQueryParams & {
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
): Promise<PaginatedGradeRecords> => {
|
||||
const conditions = []
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
const scopeFilter = buildScopeClassFilter(params.scope, params.currentUserId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
@@ -71,15 +81,24 @@ export const getGradeRecords = cache(
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// 并行查询总数与当前页数据
|
||||
const [countRow, rows] = await Promise.all([
|
||||
db.select({ total: count() }).from(gradeRecords).where(whereClause),
|
||||
db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
.limit(params.limit ?? 1000)
|
||||
.offset(params.offset ?? 0),
|
||||
])
|
||||
|
||||
if (rows.length === 0) return []
|
||||
const total = toNumber(countRow[0]?.total)
|
||||
if (rows.length === 0) return { records: [], total }
|
||||
|
||||
// Batch fetch display names via cross-module interfaces
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
|
||||
@@ -96,7 +115,7 @@ export const getGradeRecords = cache(
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
return rows.map((r) => ({
|
||||
const records: GradeRecordListItem[] = rows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
|
||||
@@ -115,6 +134,8 @@ export const getGradeRecords = cache(
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
return { records, total }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -149,7 +170,7 @@ export async function createGradeRecord(
|
||||
export async function batchCreateGradeRecords(
|
||||
data: BatchCreateGradeRecordInput,
|
||||
recordedBy: string
|
||||
): Promise<number> {
|
||||
): Promise<string[]> {
|
||||
const rows = data.records.map((r) => ({
|
||||
id: createId(),
|
||||
studentId: r.studentId,
|
||||
@@ -166,15 +187,54 @@ export async function batchCreateGradeRecords(
|
||||
remark: r.remark ?? null,
|
||||
}))
|
||||
|
||||
if (rows.length === 0) return []
|
||||
// P3 修复:批量插入使用事务,确保原子性
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(gradeRecords).values(rows)
|
||||
})
|
||||
// v3-P2-3:返回创建的记录 ID 列表,供前端撤销使用
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-3: 批量撤销成绩录入。
|
||||
* 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。
|
||||
*/
|
||||
export async function undoBatchCreateGradeRecords(
|
||||
ids: string[],
|
||||
recordedBy: string
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) return 0
|
||||
// 仅允许撤销自己录入的记录,避免越权删除
|
||||
const rows = await db
|
||||
.select({ id: gradeRecords.id })
|
||||
.from(gradeRecords)
|
||||
.where(
|
||||
and(
|
||||
inArray(gradeRecords.id, ids),
|
||||
eq(gradeRecords.recordedBy, recordedBy)
|
||||
)
|
||||
)
|
||||
if (rows.length === 0) return 0
|
||||
await db.insert(gradeRecords).values(rows)
|
||||
return rows.length
|
||||
const allowedIds = rows.map((r) => r.id)
|
||||
await db.delete(gradeRecords).where(inArray(gradeRecords.id, allowedIds))
|
||||
return allowedIds.length
|
||||
}
|
||||
|
||||
export async function updateGradeRecord(
|
||||
id: string,
|
||||
data: UpdateGradeRecordInput
|
||||
): Promise<void> {
|
||||
// P3 修复:检查记录是否存在
|
||||
const [existing] = await db
|
||||
.select({ id: gradeRecords.id })
|
||||
.from(gradeRecords)
|
||||
.where(eq(gradeRecords.id, id))
|
||||
.limit(1)
|
||||
if (!existing) {
|
||||
throw new NotFoundError("成绩记录")
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (data.title !== undefined) update.title = data.title
|
||||
if (data.score !== undefined) update.score = String(data.score)
|
||||
@@ -188,19 +248,55 @@ export async function updateGradeRecord(
|
||||
}
|
||||
|
||||
export async function deleteGradeRecord(id: string): Promise<void> {
|
||||
// P3 修复:检查记录是否存在
|
||||
const [existing] = await db
|
||||
.select({ id: gradeRecords.id })
|
||||
.from(gradeRecords)
|
||||
.where(eq(gradeRecords.id, id))
|
||||
.limit(1)
|
||||
if (!existing) {
|
||||
throw new NotFoundError("成绩记录")
|
||||
}
|
||||
|
||||
await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P3-2: 批量删除成绩记录。
|
||||
* 使用 inArray 一次性删除,避免 N+1 查询。
|
||||
* 返回实际删除的记录数。
|
||||
*/
|
||||
export async function bulkDeleteGradeRecords(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) return 0
|
||||
// 先查询存在的记录,避免删除不存在的 ID 时影响计数
|
||||
const rows = await db
|
||||
.select({ id: gradeRecords.id })
|
||||
.from(gradeRecords)
|
||||
.where(inArray(gradeRecords.id, ids))
|
||||
if (rows.length === 0) return 0
|
||||
const existingIds = rows.map((r) => r.id)
|
||||
await db.delete(gradeRecords).where(inArray(gradeRecords.id, existingIds))
|
||||
return existingIds.length
|
||||
}
|
||||
|
||||
export const getClassGradeStats = cache(
|
||||
async (
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
examId?: string,
|
||||
scope?: DataScope,
|
||||
currentUserId?: string
|
||||
): Promise<GradeStats | null> => {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
|
||||
// P3 修复:添加 scope 过滤
|
||||
if (scope) {
|
||||
const scopeFilter = buildScopeClassFilter(scope, currentUserId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
@@ -214,17 +310,37 @@ export const getClassGradeStats = cache(
|
||||
)
|
||||
|
||||
export const getStudentGradeSummary = cache(
|
||||
async (studentId: string): Promise<StudentGradeSummary | null> => {
|
||||
async (
|
||||
studentId: string,
|
||||
scope?: DataScope
|
||||
): Promise<StudentGradeSummary | null> => {
|
||||
// P3 修复:对 class_taught scope 校验学生是否属于教师所教的班级
|
||||
if (scope?.type === "class_taught") {
|
||||
const allowedClassIds = new Set(scope.classIds)
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
if (studentClassId && !allowedClassIds.has(studentClassId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const studentNameMap = await getUserNamesByIds([studentId])
|
||||
const studentName = studentNameMap.get(studentId)?.name ?? null
|
||||
if (!studentName && !studentNameMap.has(studentId)) return null
|
||||
|
||||
const conditions = [eq(gradeRecords.studentId, studentId)]
|
||||
|
||||
// 应用 scope 过滤
|
||||
if (scope) {
|
||||
const scopeFilter = buildScopeClassFilter(scope, studentId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const records = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(eq(gradeRecords.studentId, studentId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
|
||||
if (records.length === 0) {
|
||||
@@ -239,10 +355,12 @@ export const getStudentGradeSummary = cache(
|
||||
|
||||
// Batch fetch display names via cross-module interfaces
|
||||
const classIds = Array.from(new Set(records.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const recorderIds = Array.from(new Set(records.map((r) => r.record.recordedBy)))
|
||||
|
||||
const [classNameMap, subjectOptions] = await Promise.all([
|
||||
const [classNameMap, subjectOptions, recorderNameMap] = await Promise.all([
|
||||
getClassNamesByIds(classIds),
|
||||
getSubjectOptions(),
|
||||
getUserNamesByIds(recorderIds),
|
||||
])
|
||||
|
||||
const subjectNameById = new Map<string, string>()
|
||||
@@ -263,19 +381,28 @@ export const getStudentGradeSummary = cache(
|
||||
type: r.record.type,
|
||||
semester: r.record.semester,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: "Unknown",
|
||||
recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown",
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const avg = computeAverageScore(listItems.map((i) => i.score))
|
||||
|
||||
// v3-P1-3 修复:计算实际班级排名(不再硬编码为 0)
|
||||
let rank = 0
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
if (studentClassId) {
|
||||
const ranking = await getClassRanking(studentClassId, undefined, undefined, scope, studentId)
|
||||
const found = ranking.find((r) => r.studentId === studentId)
|
||||
if (found) rank = found.rank
|
||||
}
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: studentName ?? "Unknown",
|
||||
records: listItems,
|
||||
averageScore: avg,
|
||||
rank: 0,
|
||||
rank,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -284,12 +411,20 @@ export const getClassRanking = cache(
|
||||
async (
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
examId?: string,
|
||||
scope?: DataScope,
|
||||
currentUserId?: string
|
||||
): Promise<ClassRankingItem[]> => {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
|
||||
// P3 修复:添加 scope 过滤
|
||||
if (scope) {
|
||||
const scopeFilter = buildScopeClassFilter(scope, currentUserId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: gradeRecords.studentId,
|
||||
@@ -306,19 +441,45 @@ export const getClassRanking = cache(
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.studentId)))
|
||||
const studentNameMap = await getUserNamesByIds(studentIds)
|
||||
|
||||
return rows.map((r, idx) => ({
|
||||
// P3 修复:处理并列排名(相同平均分获得相同名次)
|
||||
const ranked: ClassRankingItem[] = []
|
||||
let lastAvg: number | null = null
|
||||
let lastRank = 0
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const r = rows[i]
|
||||
const avg = Math.round(toNumber(r.avgScore) * 100) / 100
|
||||
// 若与上一名平均分相同,则共享名次;否则名次为当前位置 +1
|
||||
if (lastAvg !== null && avg === lastAvg) {
|
||||
// 共享上一名的 rank
|
||||
} else {
|
||||
lastRank = i + 1
|
||||
lastAvg = avg
|
||||
}
|
||||
ranked.push({
|
||||
studentId: r.studentId,
|
||||
studentName: studentNameMap.get(r.studentId)?.name ?? "Unknown",
|
||||
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
|
||||
rank: idx + 1,
|
||||
averageScore: avg,
|
||||
rank: lastRank,
|
||||
recordCount: toNumber(r.recordCount),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return ranked
|
||||
}
|
||||
)
|
||||
|
||||
export const getClassStudentsForEntry = cache(async (classId: string): Promise<
|
||||
Array<{ id: string; name: string; email: string }>
|
||||
> => {
|
||||
export const getClassStudentsForEntry = cache(
|
||||
async (
|
||||
classId: string,
|
||||
scope?: DataScope
|
||||
): Promise<Array<{ id: string; name: string; email: string }>> => {
|
||||
// P3 修复:对 class_taught scope 校验 classId
|
||||
if (scope?.type === "class_taught") {
|
||||
if (!scope.classIds.includes(classId)) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
@@ -334,19 +495,22 @@ export const getClassStudentsForEntry = cache(async (classId: string): Promise<
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const getClassGradeStatsWithMeta = cache(
|
||||
async (
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
examId?: string,
|
||||
scope?: DataScope,
|
||||
currentUserId?: string
|
||||
): Promise<ClassGradeStats | null> => {
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) return null
|
||||
|
||||
const className = await getClassNameById(classId)
|
||||
const stats = await getClassGradeStats(classId, subjectId, examId)
|
||||
const stats = await getClassGradeStats(classId, subjectId, examId, scope, currentUserId)
|
||||
if (!stats) {
|
||||
return {
|
||||
classId,
|
||||
@@ -375,3 +539,107 @@ export const getClassGradeStatsWithMeta = cache(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// --- v3-P2-10: 服务端草稿自动保存 ---
|
||||
|
||||
export interface GradeDraftData {
|
||||
scores: Record<string, string>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存成绩录入草稿到 DB(upsert)。
|
||||
* 每位教师对每个 classId+subjectId+type 组合只有一个草稿。
|
||||
*/
|
||||
export async function saveGradeDraft(params: {
|
||||
userId: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
data: GradeDraftData
|
||||
}): Promise<void> {
|
||||
const { userId, classId, subjectId, type, data } = params
|
||||
|
||||
// upsert:存在则更新,不存在则插入
|
||||
const existing = await db.query.gradeDrafts.findFirst({
|
||||
where: and(
|
||||
eq(gradeDrafts.userId, userId),
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type)
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(gradeDrafts)
|
||||
.set({ content: data })
|
||||
.where(eq(gradeDrafts.id, existing.id))
|
||||
} else {
|
||||
await db.insert(gradeDrafts).values({
|
||||
id: createId(),
|
||||
userId,
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
content: data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成绩录入草稿。
|
||||
* 超过 24 小时的草稿视为过期,返回 null。
|
||||
*/
|
||||
export const getGradeDraft = cache(
|
||||
async (params: {
|
||||
userId: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
}): Promise<GradeDraftData | null> => {
|
||||
const { userId, classId, subjectId, type } = params
|
||||
|
||||
const draft = await db.query.gradeDrafts.findFirst({
|
||||
where: and(
|
||||
eq(gradeDrafts.userId, userId),
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type)
|
||||
),
|
||||
})
|
||||
|
||||
if (!draft) return null
|
||||
|
||||
const content = draft.content as GradeDraftData
|
||||
// 24 小时过期
|
||||
if (Date.now() - content.timestamp > 24 * 60 * 60 * 1000) {
|
||||
return null
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 删除成绩录入草稿(提交成功后调用)。
|
||||
*/
|
||||
export async function deleteGradeDraft(params: {
|
||||
userId: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
}): Promise<void> {
|
||||
const { userId, classId, subjectId, type } = params
|
||||
|
||||
await db
|
||||
.delete(gradeDrafts)
|
||||
.where(
|
||||
and(
|
||||
eq(gradeDrafts.userId, userId),
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getClassNameById } from "@/modules/classes/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
@@ -7,14 +9,7 @@ import type { DataScope } from "@/shared/types/permissions"
|
||||
import { exportToExcel } from "@/shared/lib/excel"
|
||||
|
||||
import { getClassGradeStats, getGradeRecords } from "./data-access"
|
||||
import type { GradeRecordType } from "./types"
|
||||
|
||||
const TYPE_LABELS: Record<GradeRecordType, string> = {
|
||||
exam: "考试",
|
||||
quiz: "测验",
|
||||
homework: "作业",
|
||||
other: "其他",
|
||||
}
|
||||
import { computeAverageScore } from "./stats-service"
|
||||
|
||||
/**
|
||||
* 导出成绩单
|
||||
@@ -26,66 +21,77 @@ export async function exportGradeRecordsToExcel(params: {
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}): Promise<Buffer> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const records = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
currentUserId: params.currentUserId,
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
examId: params.examId,
|
||||
})
|
||||
|
||||
const detailRows = records.map((r) => ({
|
||||
const detailRows = records.records.map((r) => ({
|
||||
studentName: r.studentName,
|
||||
className: r.className,
|
||||
subjectName: r.subjectName,
|
||||
title: r.title,
|
||||
score: r.score,
|
||||
fullScore: r.fullScore,
|
||||
type: TYPE_LABELS[r.type] ?? r.type,
|
||||
semester: r.semester === "1" ? "第一学期" : "第二学期",
|
||||
type: t(`type.${r.type}`),
|
||||
semester: t(`semester.${r.semester === "1" ? "s1" : "s2"}`),
|
||||
recorderName: r.recorderName,
|
||||
remark: r.remark ?? "",
|
||||
createdAt: r.createdAt.split("T")[0],
|
||||
}))
|
||||
|
||||
const stats = await getClassGradeStats(params.classId, params.subjectId, params.examId)
|
||||
// P3 修复:调用 getClassGradeStats 时传入 scope
|
||||
const stats = await getClassGradeStats(
|
||||
params.classId,
|
||||
params.subjectId,
|
||||
params.examId,
|
||||
params.scope,
|
||||
params.currentUserId
|
||||
)
|
||||
const statsRows = stats
|
||||
? [
|
||||
{ metric: "均分", value: stats.average },
|
||||
{ metric: "中位数", value: stats.median },
|
||||
{ metric: "最高分", value: stats.max },
|
||||
{ metric: "最低分", value: stats.min },
|
||||
{ metric: "标准差", value: stats.stdDev },
|
||||
{ metric: "及格率(%)", value: stats.passRate },
|
||||
{ metric: "优秀率(%)", value: stats.excellentRate },
|
||||
{ metric: "参考人数", value: stats.count },
|
||||
{ metric: t("export.metrics.average"), value: stats.average },
|
||||
{ metric: t("export.metrics.median"), value: stats.median },
|
||||
{ metric: t("export.metrics.max"), value: stats.max },
|
||||
{ metric: t("export.metrics.min"), value: stats.min },
|
||||
{ metric: t("export.metrics.stdDev"), value: stats.stdDev },
|
||||
{ metric: t("export.metrics.passRate"), value: stats.passRate },
|
||||
{ metric: t("export.metrics.excellentRate"), value: stats.excellentRate },
|
||||
{ metric: t("export.metrics.count"), value: stats.count },
|
||||
]
|
||||
: [{ metric: "无数据", value: "" }]
|
||||
: [{ metric: t("export.metrics.noData"), value: "" }]
|
||||
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "成绩明细",
|
||||
name: t("export.sheets.detail"),
|
||||
columns: [
|
||||
{ header: "学生姓名", key: "studentName", width: 16 },
|
||||
{ header: "班级", key: "className", width: 20 },
|
||||
{ header: "科目", key: "subjectName", width: 14 },
|
||||
{ header: "标题", key: "title", width: 24 },
|
||||
{ header: "分数", key: "score", width: 10 },
|
||||
{ header: "满分", key: "fullScore", width: 10 },
|
||||
{ header: "类型", key: "type", width: 10 },
|
||||
{ header: "学期", key: "semester", width: 12 },
|
||||
{ header: "录入人", key: "recorderName", width: 14 },
|
||||
{ header: "备注", key: "remark", width: 24 },
|
||||
{ header: "录入日期", key: "createdAt", width: 14 },
|
||||
{ header: t("export.columns.studentName"), key: "studentName", width: 16 },
|
||||
{ header: t("export.columns.class"), key: "className", width: 20 },
|
||||
{ header: t("export.columns.subject"), key: "subjectName", width: 14 },
|
||||
{ header: t("export.columns.title"), key: "title", width: 24 },
|
||||
{ header: t("export.columns.score"), key: "score", width: 10 },
|
||||
{ header: t("export.columns.fullScore"), key: "fullScore", width: 10 },
|
||||
{ header: t("export.columns.type"), key: "type", width: 10 },
|
||||
{ header: t("export.columns.semester"), key: "semester", width: 12 },
|
||||
{ header: t("export.columns.recorder"), key: "recorderName", width: 14 },
|
||||
{ header: t("export.columns.remark"), key: "remark", width: 24 },
|
||||
{ header: t("export.columns.date"), key: "createdAt", width: 14 },
|
||||
],
|
||||
rows: detailRows,
|
||||
},
|
||||
{
|
||||
name: "统计汇总",
|
||||
name: t("export.sheets.summary"),
|
||||
columns: [
|
||||
{ header: "指标", key: "metric", width: 20 },
|
||||
{ header: "数值", key: "value", width: 16 },
|
||||
{ header: t("export.columns.metric"), key: "metric", width: 20 },
|
||||
{ header: t("export.columns.value"), key: "value", width: 16 },
|
||||
],
|
||||
rows: statsRows,
|
||||
},
|
||||
@@ -99,14 +105,19 @@ export async function exportGradeRecordsToExcel(params: {
|
||||
export async function exportClassGradeReportToExcel(params: {
|
||||
classId: string
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}): Promise<Buffer> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const className = (await getClassNameById(params.classId)) ?? "Unknown"
|
||||
|
||||
// Get all grade records for this class (already includes student/subject names via cross-module interfaces)
|
||||
const allRecords = await getGradeRecords({
|
||||
const allRecordsResult = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
currentUserId: params.currentUserId,
|
||||
classId: params.classId,
|
||||
})
|
||||
const allRecords = allRecordsResult.records
|
||||
|
||||
// Extract unique subjects and students from the records
|
||||
const subjectIds = Array.from(new Set(allRecords.map((r) => r.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
@@ -145,19 +156,16 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
subjMap.set(r.subjectId, arr)
|
||||
}
|
||||
|
||||
const avg = (arr: number[]) =>
|
||||
arr.length > 0 ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : 0
|
||||
|
||||
const columns = [
|
||||
{ header: "学生姓名", key: "studentName", width: 16 },
|
||||
{ header: t("export.columns.studentName"), key: "studentName", width: 16 },
|
||||
...subjectRows.map((s) => ({
|
||||
header: s.name,
|
||||
key: s.id,
|
||||
width: 14,
|
||||
})),
|
||||
{ header: "总分", key: "_total", width: 12 },
|
||||
{ header: "平均分", key: "_average", width: 12 },
|
||||
{ header: "排名", key: "_rank", width: 10 },
|
||||
{ header: t("export.columns.total"), key: "_total", width: 12 },
|
||||
{ header: t("export.columns.average"), key: "_average", width: 12 },
|
||||
{ header: t("export.columns.rank"), key: "_rank", width: 10 },
|
||||
]
|
||||
|
||||
const rowsData = studentRows.map((student) => {
|
||||
@@ -169,7 +177,7 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
let count = 0
|
||||
for (const subj of subjectRows) {
|
||||
const scores = subjMap.get(subj.id) ?? []
|
||||
const score = avg(scores)
|
||||
const score = computeAverageScore(scores)
|
||||
row[subj.id] = scores.length > 0 ? score : "-"
|
||||
if (scores.length > 0) {
|
||||
total += score
|
||||
@@ -191,10 +199,92 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: `${className}_成绩总表`,
|
||||
name: t("export.sheets.classReport", { className }),
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* v4-P1-12: 导出单个学生的成绩单(家长视角)。
|
||||
* 仅导出成绩明细,不含班级统计(家长无权查看全班数据)。
|
||||
*/
|
||||
export async function exportStudentGradeRecordsToExcel(params: {
|
||||
studentId: string
|
||||
subjectId?: string
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}): Promise<Buffer> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
// 查询该学生的所有成绩记录(scope 为 children,会自动按 studentId 过滤)
|
||||
const records = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
currentUserId: params.currentUserId,
|
||||
subjectId: params.subjectId,
|
||||
})
|
||||
|
||||
// 仅保留指定学生的记录
|
||||
const studentRecords = records.records.filter((r) => r.studentId === params.studentId)
|
||||
|
||||
const detailRows = studentRecords.map((r) => ({
|
||||
studentName: r.studentName,
|
||||
className: r.className,
|
||||
subjectName: r.subjectName,
|
||||
title: r.title,
|
||||
score: r.score,
|
||||
fullScore: r.fullScore,
|
||||
type: t(`type.${r.type}`),
|
||||
semester: t(`semester.${r.semester === "1" ? "s1" : "s2"}`),
|
||||
recorderName: r.recorderName,
|
||||
remark: r.remark ?? "",
|
||||
createdAt: r.createdAt.split("T")[0],
|
||||
}))
|
||||
|
||||
// 计算该学生的个人统计
|
||||
const scores = studentRecords.map((r) => r.score)
|
||||
const average = scores.length > 0
|
||||
? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 100) / 100
|
||||
: 0
|
||||
const maxScore = scores.length > 0 ? Math.max(...scores) : 0
|
||||
const minScore = scores.length > 0 ? Math.min(...scores) : 0
|
||||
|
||||
const statsRows = [
|
||||
{ metric: t("export.metrics.count"), value: scores.length },
|
||||
{ metric: t("export.metrics.average"), value: average },
|
||||
{ metric: t("export.metrics.max"), value: maxScore },
|
||||
{ metric: t("export.metrics.min"), value: minScore },
|
||||
]
|
||||
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: t("export.sheets.detail"),
|
||||
columns: [
|
||||
{ header: t("export.columns.studentName"), key: "studentName", width: 16 },
|
||||
{ header: t("export.columns.class"), key: "className", width: 20 },
|
||||
{ header: t("export.columns.subject"), key: "subjectName", width: 14 },
|
||||
{ header: t("export.columns.title"), key: "title", width: 24 },
|
||||
{ header: t("export.columns.score"), key: "score", width: 10 },
|
||||
{ header: t("export.columns.fullScore"), key: "fullScore", width: 10 },
|
||||
{ header: t("export.columns.type"), key: "type", width: 10 },
|
||||
{ header: t("export.columns.semester"), key: "semester", width: 12 },
|
||||
{ header: t("export.columns.recorder"), key: "recorderName", width: 14 },
|
||||
{ header: t("export.columns.remark"), key: "remark", width: 24 },
|
||||
{ header: t("export.columns.date"), key: "createdAt", width: 14 },
|
||||
],
|
||||
rows: detailRows,
|
||||
},
|
||||
{
|
||||
name: t("export.sheets.summary"),
|
||||
columns: [
|
||||
{ header: t("export.columns.metric"), key: "metric", width: 20 },
|
||||
{ header: t("export.columns.value"), key: "value", width: 16 },
|
||||
],
|
||||
rows: statsRows,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classes, 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.
|
||||
@@ -26,36 +18,3 @@ 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") {
|
||||
// P2-4 修复:grade_managed scope 应返回所管年级的班级成绩记录
|
||||
// 通过子查询过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))
|
||||
return scope.gradeIds.length > 0
|
||||
? inArray(
|
||||
gradeRecords.classId,
|
||||
db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, scope.gradeIds))
|
||||
)
|
||||
: 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`
|
||||
}
|
||||
|
||||
51
src/modules/grades/lib/scope-filter.ts
Normal file
51
src/modules/grades/lib/scope-filter.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 { getClassIdsByGradeIdsSubquery } from "@/modules/classes/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
* 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 scope).
|
||||
*
|
||||
* @param scope Current user's DataScope
|
||||
* @param currentUserId Current user's id (required for `class_members` scope
|
||||
* to filter by the student's own records; ignored for other scopes)
|
||||
*/
|
||||
export const buildScopeClassFilter = (
|
||||
scope: DataScope,
|
||||
currentUserId?: string
|
||||
): 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") {
|
||||
// P2-4 修复:grade_managed scope 应返回所管年级的班级成绩记录
|
||||
// P2-2 修复:通过 classes data-access 提供的子查询函数构建 SQL,不直接查询 classes 表
|
||||
return scope.gradeIds.length > 0
|
||||
? inArray(
|
||||
gradeRecords.classId,
|
||||
getClassIdsByGradeIdsSubquery(scope.gradeIds)
|
||||
)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "class_members") {
|
||||
// P3 修复:class_members scope 内置 studentId 条件,避免调用方遗漏
|
||||
return currentUserId
|
||||
? eq(gradeRecords.studentId, currentUserId)
|
||||
: sql`1=0`
|
||||
}
|
||||
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`
|
||||
}
|
||||
12
src/modules/grades/lib/type-guards.ts
Normal file
12
src/modules/grades/lib/type-guards.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { GradeRecordType, GradeRecordSemester } from "../types"
|
||||
|
||||
const GRADE_TYPES: readonly GradeRecordType[] = ["exam", "quiz", "homework", "other"]
|
||||
const SEMESTERS: readonly GradeRecordSemester[] = ["1", "2"]
|
||||
|
||||
export function isGradeType(v: string): v is GradeRecordType {
|
||||
return (GRADE_TYPES as readonly string[]).includes(v)
|
||||
}
|
||||
|
||||
export function isSemester(v: string): v is GradeRecordSemester {
|
||||
return (SEMESTERS as readonly string[]).includes(v)
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export const CreateGradeRecordSchema = z.object({
|
||||
examId: z.string().optional(),
|
||||
academicYearId: z.string().optional(),
|
||||
title: z.string().min(1).max(255),
|
||||
score: z.coerce.number().min(0),
|
||||
fullScore: z.coerce.number().min(1).optional(),
|
||||
score: z.coerce.number().min(0).max(1000),
|
||||
fullScore: z.coerce.number().min(1).max(1000).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
remark: z.string().optional(),
|
||||
@@ -21,7 +21,7 @@ export type CreateGradeRecordInput = z.infer<typeof CreateGradeRecordSchema>
|
||||
|
||||
export const BatchGradeRecordItemSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
score: z.coerce.number().min(0),
|
||||
score: z.coerce.number().min(0).max(1000),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -31,18 +31,18 @@ export const BatchCreateGradeRecordSchema = z.object({
|
||||
examId: z.string().optional(),
|
||||
academicYearId: z.string().optional(),
|
||||
title: z.string().min(1).max(255),
|
||||
fullScore: z.coerce.number().min(1).optional(),
|
||||
fullScore: z.coerce.number().min(1).max(1000).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
records: z.array(BatchGradeRecordItemSchema),
|
||||
records: z.array(BatchGradeRecordItemSchema).max(500),
|
||||
})
|
||||
|
||||
export type BatchCreateGradeRecordInput = z.infer<typeof BatchCreateGradeRecordSchema>
|
||||
|
||||
export const UpdateGradeRecordSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
score: z.coerce.number().min(0).optional(),
|
||||
fullScore: z.coerce.number().min(1).optional(),
|
||||
score: z.coerce.number().min(0).max(1000).optional(),
|
||||
fullScore: z.coerce.number().min(1).max(1000).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
remark: z.string().optional(),
|
||||
@@ -76,22 +76,27 @@ export const ClassGradeStatsQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const StudentGradeSummaryQuerySchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const ClassRankingQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const ExportGradesSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
classId: z.string().min(1).optional(),
|
||||
studentId: z.string().min(1).optional(),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
reportType: z.enum(["detail", "class"]).optional(),
|
||||
})
|
||||
|
||||
@@ -100,20 +105,29 @@ export type ExportGradesInput = z.infer<typeof ExportGradesSchema>
|
||||
export const GradeTrendQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
studentId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const ClassComparisonQuerySchema = z.object({
|
||||
gradeId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const SubjectComparisonQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const GradeDistributionQuerySchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
export const RankingTrendQuerySchema = z.object({
|
||||
|
||||
@@ -84,6 +84,13 @@ export function computeAverageScore(scores: number[]): number {
|
||||
return round2(scores.reduce((a, b) => a + b, 0) / scores.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: check if a value is a valid GradeTrendPoint type.
|
||||
*/
|
||||
function isGradeTrendType(v: unknown): v is GradeTrendPoint["type"] {
|
||||
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a grade trend point list from raw DB rows (already ordered by date asc).
|
||||
*/
|
||||
@@ -107,7 +114,7 @@ export function buildGradeTrendPoints(
|
||||
score,
|
||||
fullScore,
|
||||
normalizedScore: normalize(score, fullScore),
|
||||
type: r.record.type as GradeTrendPoint["type"],
|
||||
type: isGradeTrendType(r.record.type) ? r.record.type : "other",
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -226,7 +233,7 @@ export function computeSubjectComparisonStats(
|
||||
/**
|
||||
* Default distribution buckets (90-100, 80-89, 70-79, 60-69, <60).
|
||||
*/
|
||||
export function createDefaultBuckets(): GradeDistributionBucket[] {
|
||||
function createDefaultBuckets(): GradeDistributionBucket[] {
|
||||
return [
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { StatusVariantMap } from "@/shared/components/ui/status-badge"
|
||||
|
||||
export type GradeRecordType = "exam" | "quiz" | "homework" | "other"
|
||||
export type GradeRecordSemester = "1" | "2"
|
||||
|
||||
/** 成绩记录类型 → Badge variant 映射(用于 grade-record-list / student-grade-summary) */
|
||||
export const GRADE_TYPE_VARIANT: StatusVariantMap<GradeRecordType> = {
|
||||
exam: "default",
|
||||
quiz: "secondary",
|
||||
homework: "outline",
|
||||
other: "outline",
|
||||
}
|
||||
|
||||
export interface GradeRecord {
|
||||
id: string
|
||||
studentId: string
|
||||
@@ -57,6 +67,34 @@ export interface ClassGradeStats {
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-9: 全校成绩汇总(按年级聚合)。
|
||||
*/
|
||||
export interface SchoolWideGradeSummaryItem {
|
||||
gradeId: string
|
||||
gradeName: string
|
||||
schoolName: string
|
||||
classCount: number
|
||||
studentCount: number
|
||||
averageScore: number
|
||||
passRate: number
|
||||
excellentRate: number
|
||||
recordCount: number
|
||||
}
|
||||
|
||||
export interface SchoolWideGradeSummary {
|
||||
grades: SchoolWideGradeSummaryItem[]
|
||||
totals: {
|
||||
gradeCount: number
|
||||
classCount: number
|
||||
studentCount: number
|
||||
recordCount: number
|
||||
averageScore: number
|
||||
passRate: number
|
||||
excellentRate: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface StudentGradeSummary {
|
||||
studentId: string
|
||||
studentName: string
|
||||
@@ -174,3 +212,29 @@ export interface RankingTrendResult {
|
||||
studentName: string
|
||||
points: RankingTrendPoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-2: 班级平均成绩趋势点(按 assessment title 分组)。
|
||||
* 用于在学生个人趋势图上叠加班级平均对比线。
|
||||
*/
|
||||
export interface ClassAverageTrendPoint {
|
||||
/** Assessment title(与 RankingTrendPoint.title 对齐) */
|
||||
title: string
|
||||
/** ISO date string */
|
||||
date: string
|
||||
/** 班级本次 assessment 的平均分(normalized 0-100) */
|
||||
averageScore: number
|
||||
/** 参与本次 assessment 的学生数 */
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
export interface ClassAverageTrendResult {
|
||||
classId: string
|
||||
points: ClassAverageTrendPoint[]
|
||||
}
|
||||
|
||||
/** 通用选项类型(id + name),供过滤器/选择器组件复用 */
|
||||
export interface SelectOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user