feat(grades): add appeals, drafts, import, report card, and growth archive
- Add actions-appeal, actions-draft, actions-import, actions-lock for grade workflow - Add data-access-appeals, data-access-drafts, data-access-exam-entry for data layer - Add batch-grade-entry-dialog, batch-grade-entry-stats, batch-grade-entry-table - Add draft-lock-banner, excel-import-dialog for import and draft management - Add growth-archive-chart, knowledge-point-mastery-chart for analytics - Add report-card-view, report-card-print-action, report-card-print-button - Add import-export, lib/notify, lib/report-card, scope-check test, stats-service test - Add hooks directory
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use server"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
@@ -7,9 +8,12 @@ import { handleActionError } from "@/shared/lib/action-utils"
|
||||
|
||||
import {
|
||||
getClassComparison,
|
||||
getClassComparisonWithSignificance,
|
||||
getGradeDistribution,
|
||||
getGradeDistributionByGradeId,
|
||||
getGradeTrend,
|
||||
getStudentGrowthArchive,
|
||||
getStudentPositionInClassDistribution,
|
||||
getSubjectComparison,
|
||||
type ClassComparisonParams,
|
||||
type GradeDistributionByGradeParams,
|
||||
@@ -24,36 +28,51 @@ import {
|
||||
GradeDistributionQuerySchema,
|
||||
GradeTrendQuerySchema,
|
||||
RankingTrendQuerySchema,
|
||||
StudentGrowthArchiveQuerySchema,
|
||||
SubjectComparisonQuerySchema,
|
||||
} from "./schema"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
ClassComparisonResult,
|
||||
GradeDistributionByGradeResult,
|
||||
GradeDistributionResult,
|
||||
GradeDistributionWithPosition,
|
||||
GradeTrendResult,
|
||||
RankingTrendResult,
|
||||
StudentGrowthArchiveResult,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* grades 模块分析类 Server Actions。
|
||||
*
|
||||
* P1-5 重构:所有错误消息已接入 i18n(next-intl)。
|
||||
* - 校验失败使用 `t("action.invalidQuery")`
|
||||
* - scope 校验失败使用 `t(\`action.scopeError.${code}\`)`
|
||||
* - 学生/家长越权访问排名趋势使用专用翻译键
|
||||
*/
|
||||
|
||||
export async function getGradeTrendAction(
|
||||
params: Omit<GradeTrendParams, "scope" | "currentUserId">
|
||||
): Promise<ActionState<GradeTrendResult | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = GradeTrendQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const result = await getGradeTrend({
|
||||
...parsed.data,
|
||||
@@ -71,20 +90,19 @@ export async function getClassComparisonAction(
|
||||
): Promise<ActionState<ClassComparisonItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = ClassComparisonQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:对 class_taught scope 校验(gradeId 维度无法直接校验,依赖 data-access 层过滤)
|
||||
// 这里不调用 assertClassInScope,因为没有 classId 参数。
|
||||
// 对 class_taught scope 校验(gradeId 维度无法直接校验,依赖 data-access 层过滤)
|
||||
// data-access 层的 getClassComparison 已通过 buildScopeClassFilter 应用 scope 过滤。
|
||||
|
||||
const result = await getClassComparison({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
@@ -95,24 +113,56 @@ export async function getClassComparisonAction(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-5: 获取班级对比数据 + 统计显著性分析结果。
|
||||
* 在 getClassComparisonAction 基础上额外返回 Cohen's d 与 p 值。
|
||||
*/
|
||||
export async function getClassComparisonWithSignificanceAction(
|
||||
params: Omit<ClassComparisonParams, "scope">
|
||||
): Promise<ActionState<ClassComparisonResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = ClassComparisonQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getClassComparisonWithSignificance({
|
||||
...parsed.data,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubjectComparisonAction(
|
||||
params: Omit<SubjectComparisonParams, "scope">
|
||||
): Promise<ActionState<SubjectComparisonItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = SubjectComparisonQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const result = await getSubjectComparison({
|
||||
...parsed.data,
|
||||
@@ -129,19 +179,21 @@ export async function getGradeDistributionAction(
|
||||
): Promise<ActionState<GradeDistributionResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = GradeDistributionQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const result = await getGradeDistribution({
|
||||
...parsed.data,
|
||||
@@ -161,29 +213,31 @@ export async function getRankingTrendAction(
|
||||
): Promise<ActionState<RankingTrendResult | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = RankingTrendQuerySchema.safeParse({ studentId, subjectId, semester })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Students can only view their own ranking trend
|
||||
// P1-5 修复:硬编码英文消息改为 i18n
|
||||
// 学生只能查看自己的排名趋势
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||
return { success: false, message: "Can only view your own ranking trend" }
|
||||
return { success: false, message: t("action.ownRankingTrendOnly") }
|
||||
}
|
||||
// Parents can only view their children's ranking trend
|
||||
// 家长只能查看子女的排名趋势
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||
) {
|
||||
return { success: false, message: "Can only view your children's ranking trend" }
|
||||
return { success: false, message: t("action.childrenRankingTrendOnly") }
|
||||
}
|
||||
|
||||
// P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
|
||||
// 传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
|
||||
const result = await getRankingTrend(
|
||||
parsed.data.studentId,
|
||||
parsed.data.subjectId,
|
||||
@@ -205,12 +259,13 @@ export async function getGradeDistributionByGradeIdAction(
|
||||
): Promise<ActionState<GradeDistributionByGradeResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = GradeDistributionByGradeQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
@@ -224,3 +279,74 @@ export async function getGradeDistributionByGradeIdAction(
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。
|
||||
* 仅学生本人(class_members scope)可调用,查询所在班级的分布 + 自己的归一化分数。
|
||||
*/
|
||||
export async function getStudentPositionInClassDistributionAction(
|
||||
studentId: string
|
||||
): Promise<ActionState<GradeDistributionWithPosition | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (ctx.dataScope.type !== "class_members") {
|
||||
return { success: false, message: t("action.studentOnly") }
|
||||
}
|
||||
|
||||
// 学生只能查看自己的分布位置
|
||||
if (ctx.userId !== studentId) {
|
||||
return { success: false, message: t("action.ownGradesOnly") }
|
||||
}
|
||||
|
||||
const result = await getStudentPositionInClassDistribution(studentId, ctx.dataScope)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。
|
||||
*
|
||||
* 权限规则:
|
||||
* - class_members(学生):仅能查看自己的成长档案
|
||||
* - children(家长):仅能查看子女的成长档案
|
||||
* - class_taught(教师):仅能查看所教班级学生的成长档案(data-access 层校验)
|
||||
* - grade_managed / all:可查看任意学生的成长档案
|
||||
*/
|
||||
export async function getStudentGrowthArchiveAction(
|
||||
studentId: string
|
||||
): Promise<ActionState<StudentGrowthArchiveResult | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = StudentGrowthArchiveQuerySchema.safeParse({ studentId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// 学生只能查看自己的成长档案
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||
return { success: false, message: t("action.ownGradesOnly") }
|
||||
}
|
||||
// 家长只能查看子女的成长档案
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||
) {
|
||||
return { success: false, message: t("action.childrenGradesOnly") }
|
||||
}
|
||||
|
||||
const result = await getStudentGrowthArchive(parsed.data.studentId, ctx.dataScope)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
215
src/modules/grades/actions-appeal.ts
Normal file
215
src/modules/grades/actions-appeal.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
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 { getGradeRecordById } from "./data-access"
|
||||
import {
|
||||
hasPendingAppeal,
|
||||
createGradeAppeal,
|
||||
getStudentAppeals,
|
||||
getPendingAppealsForReview,
|
||||
getAppealById,
|
||||
reviewGradeAppeal,
|
||||
withdrawGradeAppeal,
|
||||
type GradeAppeal,
|
||||
type GradeAppealWithRecord,
|
||||
} from "./data-access-appeals"
|
||||
import { CreateGradeAppealSchema, ReviewGradeAppealSchema } from "./schema"
|
||||
|
||||
/**
|
||||
* P3-7 成绩申诉 Server Actions。
|
||||
*
|
||||
* 权限模型:
|
||||
* - 学生提交/撤回/查看自己的申诉:GRADE_RECORD_READ
|
||||
* - 教师复核申诉:GRADE_RECORD_MANAGE
|
||||
*
|
||||
* 状态机:pending → approved/rejected/withdrawn
|
||||
*/
|
||||
|
||||
/**
|
||||
* 学生提交成绩申诉。
|
||||
*
|
||||
* 校验:
|
||||
* 1. 成绩记录存在且属于当前用户(scope=owned 时 studentId=ctx.userId)
|
||||
* 2. 该成绩无 pending 申诉
|
||||
*/
|
||||
export async function createGradeAppealAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = CreateGradeAppealSchema.safeParse({
|
||||
gradeRecordId: formData.get("gradeRecordId"),
|
||||
reason: formData.get("reason"),
|
||||
expectedScore: formData.get("expectedScore") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// 校验成绩归属
|
||||
const record = await getGradeRecordById(parsed.data.gradeRecordId)
|
||||
if (!record) {
|
||||
return { success: false, message: t("appeal.gradeNotFound") }
|
||||
}
|
||||
if (record.studentId !== ctx.userId) {
|
||||
return { success: false, message: t("appeal.notYourGrade") }
|
||||
}
|
||||
|
||||
// 校验无 pending 申诉
|
||||
const hasPending = await hasPendingAppeal(parsed.data.gradeRecordId)
|
||||
if (hasPending) {
|
||||
return { success: false, message: t("appeal.pendingExists") }
|
||||
}
|
||||
|
||||
const id = await createGradeAppeal({
|
||||
gradeRecordId: parsed.data.gradeRecordId,
|
||||
studentId: ctx.userId,
|
||||
originalScore: record.score,
|
||||
reason: parsed.data.reason,
|
||||
expectedScore: parsed.data.expectedScore,
|
||||
})
|
||||
|
||||
revalidatePath("/student/grades")
|
||||
return { success: true, message: t("appeal.created"), data: id }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生撤回申诉。
|
||||
*/
|
||||
export async function withdrawGradeAppealAction(
|
||||
appealId: string
|
||||
): Promise<ActionState<{ withdrawn: true }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
await withdrawGradeAppeal({ appealId, studentId: ctx.userId })
|
||||
|
||||
revalidatePath("/student/grades")
|
||||
return { success: true, data: { withdrawn: true } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生自己的申诉列表。
|
||||
*/
|
||||
export async function getStudentAppealsAction(): Promise<
|
||||
ActionState<GradeAppeal[]>
|
||||
> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const appeals = await getStudentAppeals(ctx.userId)
|
||||
return { success: true, data: appeals }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 教师复核申诉(approve/reject)。
|
||||
*
|
||||
* 若 approve 且提供 adjustedScore,会同步更新 grade_records.score。
|
||||
*/
|
||||
export async function reviewGradeAppealAction(
|
||||
prevState: ActionState<{ reviewed: true }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ reviewed: true }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = ReviewGradeAppealSchema.safeParse({
|
||||
appealId: formData.get("appealId"),
|
||||
decision: formData.get("decision"),
|
||||
reviewComment: formData.get("reviewComment"),
|
||||
adjustedScore: formData.get("adjustedScore") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// 校验申诉归属:教师只能复核自己班级的申诉
|
||||
const appeal = await getAppealById(parsed.data.appealId)
|
||||
if (!appeal) {
|
||||
return { success: false, message: t("appeal.notFound") }
|
||||
}
|
||||
|
||||
const scopeError = ctx.dataScope.type === "class_taught"
|
||||
? (ctx.dataScope.classIds.includes(appeal.gradeRecordClassId) ? null : "out_of_scope")
|
||||
: ctx.dataScope.type === "all"
|
||||
? null
|
||||
: "out_of_scope"
|
||||
|
||||
if (scopeError) {
|
||||
return { success: false, message: t("appeal.outOfScope") }
|
||||
}
|
||||
|
||||
await reviewGradeAppeal({
|
||||
appealId: parsed.data.appealId,
|
||||
reviewerId: ctx.userId,
|
||||
decision: parsed.data.decision,
|
||||
reviewComment: parsed.data.reviewComment,
|
||||
adjustedScore: parsed.data.adjustedScore,
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/grades")
|
||||
revalidatePath("/student/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: parsed.data.decision === "approved"
|
||||
? t("appeal.approved")
|
||||
: t("appeal.rejected"),
|
||||
data: { reviewed: true },
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师待复核的申诉列表。
|
||||
*/
|
||||
export async function getPendingAppealsForReviewAction(): Promise<
|
||||
ActionState<GradeAppealWithRecord[]>
|
||||
> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
// 获取教师班级范围
|
||||
const classIds =
|
||||
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
|
||||
|
||||
if (ctx.dataScope.type !== "all" && classIds.length === 0) {
|
||||
return { success: true, data: [] }
|
||||
}
|
||||
|
||||
const appeals = await getPendingAppealsForReview(classIds)
|
||||
return { success: true, data: appeals }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
122
src/modules/grades/actions-draft.ts
Normal file
122
src/modules/grades/actions-draft.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
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 {
|
||||
saveGradeDraft,
|
||||
getGradeDraft,
|
||||
deleteGradeDraft,
|
||||
type GradeDraftData,
|
||||
} from "./data-access-drafts"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
|
||||
/**
|
||||
* P1-1 拆分:从 actions.ts 抽取的成绩录入草稿 Server Actions。
|
||||
* 对应 data-access 的 saveGradeDraft / getGradeDraft / deleteGradeDraft。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 保存成绩录入草稿到服务端(跨设备同步)。
|
||||
*/
|
||||
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 t = await getTranslations("grades")
|
||||
|
||||
// 校验班级权限
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${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 t = await getTranslations("grades")
|
||||
|
||||
// 校验班级权限
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${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 t = await getTranslations("grades")
|
||||
|
||||
// 校验班级权限
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
await deleteGradeDraft({
|
||||
userId: ctx.userId,
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, data: { deleted: true } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
296
src/modules/grades/actions-import.ts
Normal file
296
src/modules/grades/actions-import.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
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 { formatDateForFile } from "@/shared/lib/utils"
|
||||
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
import {
|
||||
ExcelImportGradesSchema,
|
||||
type ExcelImportGradesInput,
|
||||
} from "./schema"
|
||||
import { batchCreateGradeRecords } from "./data-access"
|
||||
import {
|
||||
generateGradeImportTemplate,
|
||||
parseGradeExcelBuffer,
|
||||
parseGradeImportData,
|
||||
buildBatchInputFromImport,
|
||||
type GradeImportResult,
|
||||
} from "./import-export"
|
||||
import { notifyGradeEntered } from "./lib/notify"
|
||||
|
||||
/**
|
||||
* P3-10: grades 模块成绩 Excel 批量导入相关 Server Actions。
|
||||
*
|
||||
* 文件拆分自 actions.ts,避免主 actions 文件超过 800 行限制。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 下载成绩导入模板(返回 base64 编码的 Excel)。
|
||||
*
|
||||
* 模板包含:
|
||||
* - 学生姓名 / 得分 / 备注 三列
|
||||
* - 前 3 行班级学生作为示例
|
||||
* - 第 2 行填写说明
|
||||
*/
|
||||
export async function downloadGradeImportTemplateAction(params: {
|
||||
classId: string
|
||||
}): Promise<ActionState<{ buffer: string; filename: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!params.classId) {
|
||||
return { success: false, message: t("action.classIdRequired") }
|
||||
}
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const buffer = await generateGradeImportTemplate({ classId: params.classId })
|
||||
const filename = t("excelImport.templateFilename", {
|
||||
date: formatDateForFile(),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
buffer: buffer.toString("base64"),
|
||||
filename,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 从 Excel 批量导入成绩。
|
||||
*
|
||||
* 输入:FormData,包含:
|
||||
* - file: Excel 文件(.xlsx / .xls)
|
||||
* - classId: 班级 ID
|
||||
* - subjectId: 科目 ID
|
||||
* - title: 评估标题
|
||||
* - examId?: 关联考试 ID
|
||||
* - academicYearId?: 学年 ID
|
||||
* - fullScore?: 满分(默认 100)
|
||||
* - type?: 类型(exam/quiz/homework/other,默认 exam)
|
||||
* - semester?: 学期("1"/"2",默认 "1")
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 权限校验:GRADE_RECORD_CREATE
|
||||
* 2. scope 校验:班级在教师所教范围内
|
||||
* 3. 文件格式校验(扩展名 + buffer 解析)
|
||||
* 4. 行级数据校验(学生姓名匹配 + score 数值校验)
|
||||
* 5. 若全部失败 → 返回错误明细
|
||||
* 6. 若部分成功 → 调用 batchCreateGradeRecords 入库
|
||||
* 7. 通知相关学生(notifyGradeEntered)
|
||||
* 8. 触发异常预警检查(notifyScoreAnomalyIfNeeded 在 actions.ts 主流程中)
|
||||
*/
|
||||
export async function importGradesFromExcelAction(
|
||||
prevState: ActionState<GradeImportResult> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<GradeImportResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
// 文件校验
|
||||
const file = formData.get("file")
|
||||
if (!(file instanceof File)) {
|
||||
return { success: false, message: t("excelImport.errorNoFile") }
|
||||
}
|
||||
|
||||
const lowerName = file.name.toLowerCase()
|
||||
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
|
||||
return { success: false, message: t("excelImport.errorInvalidFormat") }
|
||||
}
|
||||
|
||||
// 参数校验(Zod)
|
||||
const parsed = ExcelImportGradesSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
subjectId: formData.get("subjectId"),
|
||||
title: formData.get("title"),
|
||||
examId: formData.get("examId") || undefined,
|
||||
academicYearId: formData.get("academicYearId") || undefined,
|
||||
fullScore: formData.get("fullScore") || undefined,
|
||||
type: formData.get("type") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
rows: [], // rows 由 Excel 解析填充,schema 仅校验其他字段
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("excelImport.errorInvalidParams"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// scope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
// 解析 Excel
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const excelRows = await parseGradeExcelBuffer(bytes)
|
||||
|
||||
if (excelRows.length === 0) {
|
||||
return { success: false, message: t("excelImport.errorEmptyFile") }
|
||||
}
|
||||
|
||||
// 行数上限校验(与 BatchCreateGradeRecordSchema.max(500) 一致)
|
||||
if (excelRows.length > 500) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("excelImport.errorTooManyRows", { count: excelRows.length }),
|
||||
}
|
||||
}
|
||||
|
||||
// 学生匹配 + 行级校验
|
||||
const validation = await parseGradeImportData(excelRows, {
|
||||
classId: parsed.data.classId,
|
||||
})
|
||||
|
||||
// 全部失败 → 返回错误明细
|
||||
if (validation.valid.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("excelImport.errorAllInvalid", {
|
||||
count: validation.invalid.length,
|
||||
}),
|
||||
data: {
|
||||
successCount: 0,
|
||||
failedCount: validation.invalid.length,
|
||||
invalidRows: validation.invalid,
|
||||
unmatchedStudents: validation.unmatched,
|
||||
createdIds: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 构造批量录入参数
|
||||
const batchInput = buildBatchInputFromImport({
|
||||
classId: parsed.data.classId,
|
||||
subjectId: parsed.data.subjectId,
|
||||
title: parsed.data.title,
|
||||
examId: parsed.data.examId,
|
||||
academicYearId: parsed.data.academicYearId,
|
||||
fullScore: parsed.data.fullScore,
|
||||
type: parsed.data.type,
|
||||
semester: parsed.data.semester,
|
||||
rows: validation.valid,
|
||||
})
|
||||
|
||||
// 入库
|
||||
const createdIds = await batchCreateGradeRecords(batchInput, ctx.userId)
|
||||
|
||||
// 通知相关学生(失败不阻断主流程)
|
||||
try {
|
||||
await notifyGradeEntered({
|
||||
subjectId: parsed.data.subjectId,
|
||||
title: parsed.data.title,
|
||||
studentIds: validation.valid.map((r) => r.studentId),
|
||||
})
|
||||
} catch {
|
||||
// 通知失败不影响导入结果
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
revalidatePath("/teacher/grades")
|
||||
revalidatePath("/teacher/grades/entry")
|
||||
revalidatePath(`/teacher/grades?classId=${parsed.data.classId}`)
|
||||
|
||||
const result: GradeImportResult = {
|
||||
successCount: createdIds.length,
|
||||
failedCount: validation.invalid.length,
|
||||
invalidRows: validation.invalid,
|
||||
unmatchedStudents: validation.unmatched,
|
||||
createdIds,
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: t("excelImport.successMessage", {
|
||||
success: result.successCount,
|
||||
failed: result.failedCount,
|
||||
}),
|
||||
data: result,
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 获取导入预览(不写入数据库)。
|
||||
*
|
||||
* 用于上传文件后、确认提交前的预览页面。
|
||||
*/
|
||||
export async function previewGradeImportAction(params: {
|
||||
file: File
|
||||
classId: string
|
||||
}): Promise<ActionState<GradeImportResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!params.file || !(params.file instanceof File)) {
|
||||
return { success: false, message: t("excelImport.errorNoFile") }
|
||||
}
|
||||
if (!params.classId) {
|
||||
return { success: false, message: t("action.classIdRequired") }
|
||||
}
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await params.file.arrayBuffer())
|
||||
const excelRows = await parseGradeExcelBuffer(bytes)
|
||||
|
||||
if (excelRows.length === 0) {
|
||||
return { success: false, message: t("excelImport.errorEmptyFile") }
|
||||
}
|
||||
|
||||
if (excelRows.length > 500) {
|
||||
return {
|
||||
success: false,
|
||||
message: t("excelImport.errorTooManyRows", { count: excelRows.length }),
|
||||
}
|
||||
}
|
||||
|
||||
const validation = await parseGradeImportData(excelRows, {
|
||||
classId: params.classId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
successCount: validation.valid.length,
|
||||
failedCount: validation.invalid.length,
|
||||
invalidRows: validation.invalid,
|
||||
unmatchedStudents: validation.unmatched,
|
||||
createdIds: [],
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExcelImportGradesInput 类型导出(供其他模块引用)。
|
||||
*/
|
||||
export type { ExcelImportGradesInput }
|
||||
205
src/modules/grades/actions-lock.ts
Normal file
205
src/modules/grades/actions-lock.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
"use server"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
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 {
|
||||
getDraftLockStatus,
|
||||
acquireDraftLock,
|
||||
renewDraftLock,
|
||||
releaseDraftLock,
|
||||
type DraftLockStatus,
|
||||
type AcquireLockResult,
|
||||
} from "./data-access-drafts"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
/**
|
||||
* P3-6 协同录入锁 Server Actions。
|
||||
*
|
||||
* 锁的获取/续期/释放都通过 Server Action 完成,
|
||||
* 调用方需传入 classId+subjectId+type 三元组定位锁。
|
||||
* 所有 Action 强制校验 GRADE_RECORD_MANAGE 权限 + 班级 scope。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 解析锁持有者姓名(避免 data-access 跨模块 join)。
|
||||
*/
|
||||
async function enrichLockStatusName(
|
||||
status: DraftLockStatus
|
||||
): Promise<DraftLockStatus> {
|
||||
if (!status.lockedBy) return status
|
||||
const names = await getUserNamesByIds([status.lockedBy])
|
||||
const info = names.get(status.lockedBy)
|
||||
return { ...status, lockedByName: info?.name ?? null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询锁状态(进入页面时调用)。
|
||||
*/
|
||||
export async function getDraftLockStatusAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
}): Promise<ActionState<DraftLockStatus>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
const t = await getTranslations("grades")
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const status = await getDraftLockStatus({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
|
||||
return { success: true, data: await enrichLockStatusName(status) }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取编辑锁(进入编辑页或被抢占后重试时调用)。
|
||||
*/
|
||||
export async function acquireDraftLockAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
}): Promise<ActionState<AcquireLockResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
const t = await getTranslations("grades")
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const result = await acquireDraftLock({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
userId: ctx.userId,
|
||||
})
|
||||
|
||||
if (!result.acquired) {
|
||||
// 锁被他人持有,返回结构化信息让前端展示冲突提示
|
||||
const enriched = await enrichLockStatusName(result.status)
|
||||
return {
|
||||
success: false,
|
||||
data: { acquired: false, lockToken: null, status: enriched },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
acquired: true,
|
||||
lockToken: result.lockToken,
|
||||
status: result.status,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期锁(心跳)。
|
||||
*
|
||||
* 前端每 2-3 分钟调用一次,避免长时间编辑时锁过期。
|
||||
* 返回新的 lockToken,前端需替换本地保存的旧 token。
|
||||
*/
|
||||
export async function renewDraftLockAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
lockToken: string
|
||||
}): Promise<ActionState<AcquireLockResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
const t = await getTranslations("grades")
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const result = await renewDraftLock({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
userId: ctx.userId,
|
||||
lockToken: params.lockToken,
|
||||
})
|
||||
|
||||
if (!result.acquired) {
|
||||
const enriched = await enrichLockStatusName(result.status)
|
||||
return {
|
||||
success: false,
|
||||
data: { acquired: false, lockToken: null, status: enriched },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
acquired: true,
|
||||
lockToken: result.lockToken,
|
||||
status: result.status,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁(提交成功或用户主动退出时调用)。
|
||||
*
|
||||
* 失败不阻断主流程(如批量录入成功后释放锁失败仅记日志)。
|
||||
*/
|
||||
export async function releaseDraftLockAction(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
lockToken: string
|
||||
}): Promise<ActionState<{ released: true }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
|
||||
if (scopeError) {
|
||||
const t = await getTranslations("grades")
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const released = await releaseDraftLock({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
type: params.type,
|
||||
userId: ctx.userId,
|
||||
lockToken: params.lockToken,
|
||||
})
|
||||
|
||||
if (!released) {
|
||||
// 锁可能已被抢占或自动过期,返回成功以避免阻断主流程
|
||||
// 前端只需知道"无需再持有该锁"
|
||||
}
|
||||
|
||||
return { success: true, data: { released: true } }
|
||||
} catch (e) {
|
||||
// 释放失败不应阻断主流程,降级为日志
|
||||
console.error("[grades] releaseDraftLockAction failed:", e)
|
||||
return { success: true, data: { released: true } }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
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,
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
import {
|
||||
createGradeRecord,
|
||||
batchCreateGradeRecords,
|
||||
batchCreateGradeRecordsByExam,
|
||||
undoBatchCreateGradeRecords,
|
||||
updateGradeRecord,
|
||||
deleteGradeRecord,
|
||||
@@ -35,88 +32,36 @@ import {
|
||||
getClassGradeStatsWithMeta,
|
||||
getStudentGradeSummary,
|
||||
getClassRanking,
|
||||
saveGradeDraft,
|
||||
getGradeDraft,
|
||||
deleteGradeDraft,
|
||||
type GradeDraftData,
|
||||
} from "./data-access"
|
||||
import { batchCreateGradeRecordsByExam } from "./data-access-exam-entry"
|
||||
import type { PaginatedGradeRecords } from "./data-access"
|
||||
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
|
||||
import { getExamForGradeEntry } from "@/modules/exams/data-access"
|
||||
import { collectFromExamSubmission } from "@/modules/error-book/data-access"
|
||||
import {
|
||||
exportGradeRecordsToExcel,
|
||||
exportClassGradeReportToExcel,
|
||||
exportStudentGradeRecordsToExcel,
|
||||
} from "./export"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import { notifyGradeEntered, notifyScoreAnomalyIfNeeded } from "./lib/notify"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
import type { GradeQueryParams, GradeStats } from "./types"
|
||||
import type {
|
||||
StudentGradeSummary,
|
||||
ClassRankingItem,
|
||||
GradeRecord,
|
||||
} from "./types"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
|
||||
/**
|
||||
* v4-P1-6: 成绩录入后通知学生和家长。
|
||||
* 通知失败不阻断成绩录入主流程。
|
||||
* grades 模块核心 Server Actions(编排层)。
|
||||
*
|
||||
* P1-1 重构:
|
||||
* - 草稿相关 3 个 Action 已迁移至 actions-draft.ts
|
||||
* - 通知逻辑 notifyGradeEntered 已迁移至 lib/notify.ts
|
||||
* - 所有错误消息已接入 i18n(P1-3)
|
||||
* - assertClassInScope 返回错误码,由调用方通过 i18n 翻译(P1-4)
|
||||
*/
|
||||
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,
|
||||
@@ -124,6 +69,7 @@ export async function createGradeRecordAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = CreateGradeRecordSchema.safeParse({
|
||||
studentId: formData.get("studentId"),
|
||||
@@ -142,7 +88,7 @@ export async function createGradeRecordAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
@@ -174,7 +120,7 @@ export async function createGradeRecordAction(
|
||||
// 通知失败不阻断成绩录入
|
||||
}
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record created", data: id }
|
||||
return { success: true, message: t("action.recordCreated"), data: id }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
@@ -186,14 +132,15 @@ export async function batchCreateGradeRecordsAction(
|
||||
): Promise<ActionState<string[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const recordsJson = formData.get("recordsJson")
|
||||
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
|
||||
return { success: false, message: "Missing records data" }
|
||||
return { success: false, message: t("action.missingRecordsData") }
|
||||
}
|
||||
|
||||
// P3 修复:使用 safeJsonParse 替代 JSON.parse,避免 SyntaxError 暴露
|
||||
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
|
||||
const records = safeJsonParse<unknown>(recordsJson, t("action.invalidJson"))
|
||||
|
||||
const parsed = BatchCreateGradeRecordSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
@@ -210,7 +157,7 @@ export async function batchCreateGradeRecordsAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
@@ -244,10 +191,23 @@ export async function batchCreateGradeRecordsAction(
|
||||
} catch {
|
||||
// 通知失败不阻断成绩录入
|
||||
}
|
||||
|
||||
// P3-2:批量录入后检测成绩异常(下降超过 10 分 / 10%),异常时通知学生 + 家长
|
||||
try {
|
||||
await notifyScoreAnomalyIfNeeded({
|
||||
studentIds: parsed.data.records.map((r) => r.studentId),
|
||||
classId: parsed.data.classId,
|
||||
subjectId: parsed.data.subjectId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
} catch {
|
||||
// 异常检测失败不阻断成绩录入
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `Created ${ids.length} grade records`,
|
||||
message: t("action.recordsCreated", { count: ids.length }),
|
||||
data: ids,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -270,40 +230,41 @@ export async function batchCreateGradeRecordsByExamAction(
|
||||
): Promise<ActionState<string[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const examId = formData.get("examId")
|
||||
const classId = formData.get("classId")
|
||||
const recordsJson = formData.get("recordsJson")
|
||||
|
||||
if (typeof examId !== "string" || examId.length === 0) {
|
||||
return { success: false, message: "缺少试卷 ID" }
|
||||
return { success: false, message: t("action.missingExamId") }
|
||||
}
|
||||
if (typeof classId !== "string" || classId.length === 0) {
|
||||
return { success: false, message: "缺少班级 ID" }
|
||||
return { success: false, message: t("action.missingClassId") }
|
||||
}
|
||||
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
|
||||
return { success: false, message: "缺少成绩数据" }
|
||||
return { success: false, message: t("action.missingRecords") }
|
||||
}
|
||||
|
||||
// 获取试卷详情(验证试卷存在 + scope 校验 + 获取 subjectId/fullScore/title)
|
||||
const exam = await getExamForGradeEntry(examId, ctx.dataScope)
|
||||
if (!exam) {
|
||||
return { success: false, message: "试卷不存在或无权访问" }
|
||||
return { success: false, message: t("action.examNotFound") }
|
||||
}
|
||||
if (exam.questions.length === 0) {
|
||||
return { success: false, message: "试卷没有题目,无法录入" }
|
||||
return { success: false, message: t("action.examNoQuestions") }
|
||||
}
|
||||
if (!exam.subjectId) {
|
||||
return { success: false, message: "试卷未关联科目" }
|
||||
return { success: false, message: t("action.examNoSubject") }
|
||||
}
|
||||
|
||||
// 校验班级在 scope 内
|
||||
const scopeError = assertClassInScope(ctx.dataScope, classId)
|
||||
if (scopeError) {
|
||||
return { success: false, message: scopeError }
|
||||
return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
|
||||
const records = safeJsonParse<unknown>(recordsJson, t("action.invalidJson"))
|
||||
|
||||
const parsed = BatchGradeEntryByExamSchema.safeParse({
|
||||
examId,
|
||||
@@ -319,27 +280,39 @@ export async function batchCreateGradeRecordsByExamAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "表单数据无效",
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const ids = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId)
|
||||
const results = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId)
|
||||
const ids = results.map((r) => r.gradeRecordId)
|
||||
|
||||
// 更新诊断掌握度
|
||||
for (const record of parsed.data.records) {
|
||||
const totalScore = record.answers.reduce((sum, a) => sum + a.score, 0)
|
||||
try {
|
||||
await updateMasteryFromExamScore(
|
||||
record.studentId,
|
||||
parsed.data.examId,
|
||||
totalScore,
|
||||
parsed.data.fullScore,
|
||||
)
|
||||
} catch {
|
||||
// 掌握度更新失败不阻断成绩录入
|
||||
}
|
||||
}
|
||||
// 更新诊断掌握度 + 采集考试错题(并行,互不阻断)
|
||||
await Promise.allSettled(
|
||||
results.map(async (r) => {
|
||||
const totalScore = parsed.data.records.find(
|
||||
(rec) => rec.studentId === r.studentId
|
||||
)?.answers.reduce((sum, a) => sum + a.score, 0) ?? 0
|
||||
|
||||
const [, errorBookResult] = await Promise.allSettled([
|
||||
updateMasteryFromExamScore(
|
||||
r.studentId,
|
||||
parsed.data.examId,
|
||||
totalScore,
|
||||
parsed.data.fullScore,
|
||||
),
|
||||
collectFromExamSubmission(r.submissionId, r.studentId),
|
||||
])
|
||||
|
||||
if (errorBookResult.status === "rejected") {
|
||||
console.error(
|
||||
`[grade-entry] 考试错题采集失败 submission=${r.submissionId}:`,
|
||||
errorBookResult.reason,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// 通知学生和家长
|
||||
try {
|
||||
@@ -353,10 +326,22 @@ export async function batchCreateGradeRecordsByExamAction(
|
||||
// 通知失败不阻断成绩录入
|
||||
}
|
||||
|
||||
// P3-2:批量录入后检测成绩异常(下降超过 10 分 / 10%),异常时通知学生 + 家长
|
||||
try {
|
||||
await notifyScoreAnomalyIfNeeded({
|
||||
studentIds: parsed.data.records.map((r) => r.studentId),
|
||||
classId: parsed.data.classId,
|
||||
subjectId: parsed.data.subjectId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
} catch {
|
||||
// 异常检测失败不阻断成绩录入
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `录入 ${ids.length} 条成绩记录`,
|
||||
message: t("action.recordsCreated", { count: ids.length }),
|
||||
data: ids,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -373,21 +358,22 @@ export async function undoBatchCreateGradeRecordsAction(
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return { success: false, message: "No records to undo" }
|
||||
return { success: false, message: t("action.noRecordsToUndo") }
|
||||
}
|
||||
|
||||
// 防御性:限制单次撤销的记录数量,避免误传超大数组
|
||||
if (ids.length > 500) {
|
||||
return { success: false, message: "Cannot undo more than 500 records at once" }
|
||||
return { success: false, message: t("action.cannotUndoMoreThan") }
|
||||
}
|
||||
|
||||
const deletedCount = await undoBatchCreateGradeRecords(ids, ctx.userId)
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `Undone ${deletedCount} grade records`,
|
||||
message: t("action.recordsUndoneCount", { count: deletedCount }),
|
||||
data: deletedCount,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -402,6 +388,7 @@ export async function updateGradeRecordAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = UpdateGradeRecordSchema.safeParse({
|
||||
title: formData.get("title") || undefined,
|
||||
@@ -416,14 +403,14 @@ export async function updateGradeRecordAction(
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await updateGradeRecord(id, parsed.data)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record updated" }
|
||||
return { success: true, message: t("action.recordUpdated") }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
@@ -434,19 +421,20 @@ export async function deleteGradeRecordAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = DeleteGradeRecordSchema.safeParse({ id })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid id",
|
||||
message: t("action.invalidId"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await deleteGradeRecord(parsed.data.id)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record deleted" }
|
||||
return { success: true, message: t("action.recordDeleted") }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
@@ -462,20 +450,21 @@ export async function bulkDeleteGradeRecordsAction(
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return { success: false, message: "No records to delete" }
|
||||
return { success: false, message: t("action.noRecordsToDelete") }
|
||||
}
|
||||
// 防御性:限制单次删除的记录数量
|
||||
if (ids.length > 500) {
|
||||
return { success: false, message: "Cannot delete more than 500 records at once" }
|
||||
return { success: false, message: t("action.cannotDeleteMoreThan") }
|
||||
}
|
||||
|
||||
const deletedCount = await bulkDeleteGradeRecords(ids)
|
||||
revalidatePath("/teacher/grades")
|
||||
return {
|
||||
success: true,
|
||||
message: `Deleted ${deletedCount} grade records`,
|
||||
message: t("action.recordsDeletedCount", { count: deletedCount }),
|
||||
data: deletedCount,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -488,12 +477,13 @@ export async function getGradeRecordsAction(
|
||||
): Promise<ActionState<PaginatedGradeRecords>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = GradeQuerySchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
@@ -501,7 +491,7 @@ export async function getGradeRecordsAction(
|
||||
// P3 修复:对 class_taught scope 校验 classId
|
||||
if (parsed.data.classId) {
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
}
|
||||
|
||||
const result = await getGradeRecords({
|
||||
@@ -524,18 +514,19 @@ export async function getClassGradeStatsAction(
|
||||
): Promise<ActionState<GradeStats | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = ClassGradeStatsQuerySchema.safeParse({ classId, subjectId, examId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
|
||||
const result = await getClassGradeStatsWithMeta(
|
||||
parsed.data.classId,
|
||||
@@ -555,24 +546,25 @@ export async function getStudentGradeSummaryAction(
|
||||
): Promise<ActionState<StudentGradeSummary | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = StudentGradeSummaryQuerySchema.safeParse({ studentId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid student id",
|
||||
message: t("action.invalidId"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||
return { success: false, message: "Can only view your own grades" }
|
||||
return { success: false, message: t("action.ownGradesOnly") }
|
||||
}
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||
) {
|
||||
return { success: false, message: "Can only view your children's grades" }
|
||||
return { success: false, message: t("action.childrenGradesOnly") }
|
||||
}
|
||||
|
||||
// P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
|
||||
@@ -590,18 +582,19 @@ export async function getClassRankingAction(
|
||||
): Promise<ActionState<ClassRankingItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = ClassRankingQuerySchema.safeParse({ classId, subjectId, examId })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid query parameters",
|
||||
message: t("action.invalidQuery"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
|
||||
const ranking = await getClassRanking(
|
||||
parsed.data.classId,
|
||||
@@ -621,12 +614,13 @@ export async function getGradeRecordByIdAction(
|
||||
): Promise<ActionState<GradeRecord | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = GetGradeRecordByIdSchema.safeParse({ id })
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid id",
|
||||
message: t("action.invalidId"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
@@ -636,20 +630,20 @@ export async function getGradeRecordByIdAction(
|
||||
// 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" }
|
||||
return { success: false, message: t("action.ownRecordsOnly") }
|
||||
}
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(record.studentId)
|
||||
) {
|
||||
return { success: false, message: "You can only view your children's grade records" }
|
||||
return { success: false, message: t("action.childrenRecordsOnly") }
|
||||
}
|
||||
// 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: false, message: t("action.taughtClassesOnly") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,12 +665,13 @@ export async function exportGradesAction(params: {
|
||||
}): Promise<ActionState<{ buffer: string; filename: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const parsed = ExportGradesSchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid export parameters",
|
||||
message: t("action.invalidFormData"),
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
@@ -685,7 +680,7 @@ export async function exportGradesAction(params: {
|
||||
// 当提供 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" }
|
||||
return { success: false, message: t("action.exportOwnChildrenOnly") }
|
||||
}
|
||||
const buffer = await exportStudentGradeRecordsToExcel({
|
||||
studentId: parsed.data.studentId,
|
||||
@@ -693,7 +688,7 @@ export async function exportGradesAction(params: {
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
const filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||
const filename = t("action.filenameDetail", { date: formatDateForFile() })
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -705,12 +700,12 @@ export async function exportGradesAction(params: {
|
||||
|
||||
// classId 必须存在(studentId 分支已返回)
|
||||
if (!parsed.data.classId) {
|
||||
return { success: false, message: "classId is required for class export" }
|
||||
return { success: false, message: t("action.classIdRequired") }
|
||||
}
|
||||
|
||||
// P3 修复:添加 assertClassInScope 校验
|
||||
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
|
||||
if (scopeError) return { success: false, message: scopeError }
|
||||
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
|
||||
|
||||
let buffer: Buffer
|
||||
let filename: string
|
||||
@@ -721,7 +716,7 @@ export async function exportGradesAction(params: {
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
|
||||
filename = t("action.filenameClassReport", { date: formatDateForFile() })
|
||||
} else {
|
||||
buffer = await exportGradeRecordsToExcel({
|
||||
classId: parsed.data.classId,
|
||||
@@ -730,7 +725,7 @@ export async function exportGradesAction(params: {
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||
filename = t("action.filenameDetail", { date: formatDateForFile() })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -744,96 +739,3 @@ export async function exportGradesAction(params: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
64
src/modules/grades/components/batch-grade-entry-dialog.tsx
Normal file
64
src/modules/grades/components/batch-grade-entry-dialog.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
|
||||
export type PendingSwitch =
|
||||
| { kind: "exam"; examId: string; message: string }
|
||||
| { kind: "class"; classId: string; message: string }
|
||||
| null
|
||||
|
||||
interface Props {
|
||||
pendingSwitch: PendingSwitch
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换试卷/班级确认对话框。
|
||||
*
|
||||
* P1-6 重构:从 batch-grade-entry.tsx 拆分而来。
|
||||
* 替代原来的 window.confirm(),支持无障碍访问。
|
||||
*/
|
||||
export function BatchGradeEntryDialog({
|
||||
pendingSwitch,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={pendingSwitch !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCancel()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("batchByExam.confirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingSwitch?.message ?? ""}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("batchByExam.cancelAction")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>
|
||||
{t("batchByExam.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
74
src/modules/grades/components/batch-grade-entry-stats.tsx
Normal file
74
src/modules/grades/components/batch-grade-entry-stats.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { Search, AlertCircle } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export interface BatchGradeEntryStatsData {
|
||||
entered: number
|
||||
total: number
|
||||
avg: number
|
||||
max: number
|
||||
min: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: BatchGradeEntryStatsData
|
||||
hasInvalidScores: boolean
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩录入统计栏。
|
||||
*
|
||||
* P1-6 重构:从 batch-grade-entry.tsx 拆分而来。
|
||||
* 展示已录入数、均分、最高/最低分、无效分数提示及学生搜索框。
|
||||
*/
|
||||
export function BatchGradeEntryStats({
|
||||
stats,
|
||||
hasInvalidScores,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: Props): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.entered")}: {stats.entered}/{stats.total}
|
||||
</Badge>
|
||||
{stats.entered > 0 && (
|
||||
<>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.max")}: {stats.max}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.min")}: {stats.min}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{hasInvalidScores && (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
{t("batchByExam.invalidScoresBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="relative ml-auto">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("batchByExam.searchStudent")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-8 w-48 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
src/modules/grades/components/batch-grade-entry-table.tsx
Normal file
122
src/modules/grades/components/batch-grade-entry-table.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX, RefObject, KeyboardEvent, ClipboardEvent } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { ExamForGradeEntry } from "@/modules/exams/types"
|
||||
|
||||
type Student = { id: string; name: string; email: string }
|
||||
|
||||
interface Props {
|
||||
exam: ExamForGradeEntry
|
||||
students: Student[]
|
||||
scores: Record<string, Record<string, string>>
|
||||
inputRefs: RefObject<Record<string, HTMLInputElement | null>>
|
||||
onScoreChange: (studentId: string, questionId: string, value: string) => void
|
||||
onKeyDown: (e: KeyboardEvent, studentId: string, questionId: string) => void
|
||||
onPaste: (e: ClipboardEvent, studentId: string, questionId: string) => void
|
||||
computeTotal: (studentId: string) => number
|
||||
isScoreInvalid: (studentId: string, questionId: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 按试卷录入的成绩表格。
|
||||
*
|
||||
* P1-6 重构:从 batch-grade-entry.tsx 拆分而来。
|
||||
* Excel 式表格,支持多行多列分数粘贴、Enter 跳行。
|
||||
*/
|
||||
export function BatchGradeEntryTable({
|
||||
exam,
|
||||
students,
|
||||
scores,
|
||||
inputRefs,
|
||||
onScoreChange,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
computeTotal,
|
||||
isScoreInvalid,
|
||||
}: Props): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-12 text-center">#</TableHead>
|
||||
<TableHead className="min-w-32">{t("batchByExam.studentName")}</TableHead>
|
||||
{exam.questions.map((q, idx) => (
|
||||
<TableHead key={q.id} className="text-center min-w-20">
|
||||
<div className="font-medium">{t("batchByExam.question", { n: idx + 1 })}</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
({q.score}{t("batchByExam.points")})
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="text-center min-w-20">{t("batchByExam.totalScore")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{students.map((s, sIdx) => {
|
||||
const total = computeTotal(s.id)
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-center text-muted-foreground tabular-nums">
|
||||
{sIdx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
{exam.questions.map((q) => {
|
||||
const invalid = isScoreInvalid(s.id, q.id)
|
||||
const key = `${s.id}-${q.id}`
|
||||
const questionIdx = exam.questions.findIndex((qq) => qq.id === q.id) + 1
|
||||
return (
|
||||
<TableCell key={q.id} className="p-1">
|
||||
<Input
|
||||
ref={(el) => {
|
||||
inputRefs.current[key] = el
|
||||
}}
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max={q.score}
|
||||
value={scores[s.id]?.[q.id] ?? ""}
|
||||
onChange={(e) => onScoreChange(s.id, q.id, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, s.id, q.id)}
|
||||
onPaste={(e) => onPaste(e, s.id, q.id)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className={cn(
|
||||
"h-8 text-center tabular-nums",
|
||||
invalid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
aria-label={`${s.name} - ${t("batchByExam.question", { n: questionIdx })}`}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell className="text-center font-medium tabular-nums">
|
||||
{Math.round(total * 10) / 10}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -4,26 +4,20 @@ import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type Clipboard
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Search, Info, AlertCircle } from "lucide-react"
|
||||
import { Info } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
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 { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
|
||||
import { batchCreateGradeRecordsByExamAction, undoBatchCreateGradeRecordsAction } from "../actions"
|
||||
import { batchCreateGradeRecordsByExamAction } from "../actions"
|
||||
import { useBatchGradeEntryUndo } from "../hooks/use-batch-grade-entry-undo"
|
||||
import { BatchGradeEntryStats } from "./batch-grade-entry-stats"
|
||||
import { BatchGradeEntryDialog, type PendingSwitch } from "./batch-grade-entry-dialog"
|
||||
import { BatchGradeEntryTable } from "./batch-grade-entry-table"
|
||||
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
|
||||
|
||||
type Student = { id: string; name: string; email: string }
|
||||
@@ -39,11 +33,13 @@ interface Props {
|
||||
defaultClassId?: string
|
||||
}
|
||||
|
||||
/** 序列化撤销数据 */
|
||||
function serializeUndoData(ids: string[]): string {
|
||||
return JSON.stringify({ ids, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 按试卷批量录入成绩主组件。
|
||||
*
|
||||
* P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。
|
||||
* - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫(P1-7 修复 `as` 断言)
|
||||
* - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件
|
||||
*/
|
||||
export function BatchGradeEntryByExam({
|
||||
exams,
|
||||
classes,
|
||||
@@ -55,10 +51,12 @@ export function BatchGradeEntryByExam({
|
||||
}: Props): JSX.Element {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("grades")
|
||||
const { saveUndoToken, handleUndo } = useBatchGradeEntryUndo()
|
||||
const [scores, setScores] = useState<Record<string, Record<string, string>>>({})
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
const [pendingSwitch, setPendingSwitch] = useState<PendingSwitch>(null)
|
||||
|
||||
// 按试卷 gradeId 过滤班级
|
||||
const filteredClasses = useMemo(() => {
|
||||
@@ -77,7 +75,12 @@ export function BatchGradeEntryByExam({
|
||||
|
||||
const handleExamChange = (examId: string): void => {
|
||||
if (Object.keys(scores).length > 0) {
|
||||
if (!window.confirm(t("batchByExam.confirmSwitchExam"))) return
|
||||
setPendingSwitch({
|
||||
kind: "exam",
|
||||
examId,
|
||||
message: t("batchByExam.confirmSwitchExam"),
|
||||
})
|
||||
return
|
||||
}
|
||||
setScores({})
|
||||
router.push(`/teacher/grades/entry?examId=${examId}`)
|
||||
@@ -85,12 +88,28 @@ export function BatchGradeEntryByExam({
|
||||
|
||||
const handleClassChange = (classId: string): void => {
|
||||
if (Object.keys(scores).length > 0) {
|
||||
if (!window.confirm(t("batchByExam.confirmSwitchClass"))) return
|
||||
setPendingSwitch({
|
||||
kind: "class",
|
||||
classId,
|
||||
message: t("batchByExam.confirmSwitchClass"),
|
||||
})
|
||||
return
|
||||
}
|
||||
setScores({})
|
||||
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`)
|
||||
}
|
||||
|
||||
const confirmPendingSwitch = (): void => {
|
||||
if (!pendingSwitch) return
|
||||
setScores({})
|
||||
if (pendingSwitch.kind === "exam") {
|
||||
router.push(`/teacher/grades/entry?examId=${pendingSwitch.examId}`)
|
||||
} else {
|
||||
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${pendingSwitch.classId}`)
|
||||
}
|
||||
setPendingSwitch(null)
|
||||
}
|
||||
|
||||
const handleScoreChange = (studentId: string, questionId: string, value: string): void => {
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
@@ -162,14 +181,12 @@ export function BatchGradeEntryByExam({
|
||||
if (!student) return
|
||||
const cells = line.split(/\t/).filter((c) => c.trim() || c === "0")
|
||||
if (cells.length === 1) {
|
||||
// 单列:按行填充当前题目
|
||||
newScores[student.id] = {
|
||||
...(newScores[student.id] ?? {}),
|
||||
[exam.questions[startQuestionIdx].id]: cells[0].trim(),
|
||||
}
|
||||
pastedCount++
|
||||
} else {
|
||||
// 多列:按行×列填充
|
||||
cells.forEach((cell, colIdx) => {
|
||||
const question = exam.questions[startQuestionIdx + colIdx]
|
||||
if (question) {
|
||||
@@ -266,14 +283,7 @@ export function BatchGradeEntryByExam({
|
||||
setScores({})
|
||||
const createdIds = result.data ?? []
|
||||
if (createdIds.length > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"lastBatchGradeRecordIds",
|
||||
serializeUndoData(createdIds)
|
||||
)
|
||||
} catch {
|
||||
// sessionStorage 不可用时静默失败
|
||||
}
|
||||
saveUndoToken(createdIds)
|
||||
toast.success(result.message, {
|
||||
duration: 10000,
|
||||
action: {
|
||||
@@ -291,31 +301,6 @@ export function BatchGradeEntryByExam({
|
||||
}
|
||||
}
|
||||
|
||||
const handleUndo = async (): Promise<void> => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem("lastBatchGradeRecordIds")
|
||||
if (!raw) {
|
||||
toast.error(t("batchByExam.undoNoRecord"))
|
||||
return
|
||||
}
|
||||
const data = JSON.parse(raw) as { ids: string[]; timestamp: number }
|
||||
if (Date.now() - data.timestamp > 5 * 60 * 1000) {
|
||||
toast.error(t("batchByExam.undoExpired"))
|
||||
return
|
||||
}
|
||||
const result = await undoBatchCreateGradeRecordsAction(data.ids)
|
||||
if (result.success) {
|
||||
sessionStorage.removeItem("lastBatchGradeRecordIds")
|
||||
toast.success(result.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("batchByExam.undoFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("batchByExam.undoFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
// 未选试卷
|
||||
if (!exam) {
|
||||
return (
|
||||
@@ -414,101 +399,25 @@ export function BatchGradeEntryByExam({
|
||||
) : (
|
||||
<>
|
||||
{/* 统计栏 */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.entered")}: {stats.entered}/{stats.total}
|
||||
</Badge>
|
||||
{stats.entered > 0 && (
|
||||
<>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.max")}: {stats.max}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("batchByExam.min")}: {stats.min}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{hasInvalidScores && (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
{t("batchByExam.invalidScoresBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="relative ml-auto">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("batchByExam.searchStudent")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-48 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BatchGradeEntryStats
|
||||
stats={stats}
|
||||
hasInvalidScores={hasInvalidScores}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Excel 式表格 */}
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-12 text-center">#</TableHead>
|
||||
<TableHead className="min-w-[120px]">{t("batchByExam.studentName")}</TableHead>
|
||||
{exam.questions.map((q, idx) => (
|
||||
<TableHead key={q.id} className="text-center min-w-[80px]">
|
||||
<div className="font-medium">{t("batchByExam.question", { n: idx + 1 })}</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">({q.score}{t("batchByExam.points")})</div>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="text-center min-w-[80px]">{t("batchByExam.totalScore")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredStudents.map((s, sIdx) => {
|
||||
const total = computeTotal(s.id)
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-center text-muted-foreground tabular-nums">{sIdx + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
{exam.questions.map((q) => {
|
||||
const invalid = isScoreInvalid(s.id, q.id)
|
||||
const key = `${s.id}-${q.id}`
|
||||
return (
|
||||
<TableCell key={q.id} className="p-1">
|
||||
<Input
|
||||
ref={(el) => { inputRefs.current[key] = el }}
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max={q.score}
|
||||
value={scores[s.id]?.[q.id] ?? ""}
|
||||
onChange={(e) => handleScoreChange(s.id, q.id, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, s.id, q.id)}
|
||||
onPaste={(e) => handlePaste(e, s.id, q.id)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className={cn(
|
||||
"h-8 text-center tabular-nums",
|
||||
invalid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
aria-label={`${s.name} - ${t("batchByExam.question", { n: exam.questions.findIndex(qq => qq.id === q.id) + 1 })}`}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell className="text-center font-medium tabular-nums">
|
||||
{Math.round(total * 10) / 10}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BatchGradeEntryTable
|
||||
exam={exam}
|
||||
students={filteredStudents}
|
||||
scores={scores}
|
||||
inputRefs={inputRefs}
|
||||
onScoreChange={handleScoreChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
computeTotal={computeTotal}
|
||||
isScoreInvalid={isScoreInvalid}
|
||||
/>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
@@ -536,6 +445,13 @@ export function BatchGradeEntryByExam({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<BatchGradeEntryDialog
|
||||
pendingSwitch={pendingSwitch}
|
||||
onConfirm={confirmPendingSwitch}
|
||||
onCancel={() => setPendingSwitch(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,34 +13,37 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ClassComparisonItem } from "@/modules/grades/types"
|
||||
import type { ClassComparisonItem, ClassComparisonSignificance } from "@/modules/grades/types"
|
||||
|
||||
interface ClassComparisonChartProps {
|
||||
data: ClassComparisonItem[]
|
||||
/**
|
||||
* P3-5: 预计算的统计显著性结果。
|
||||
* 由 data-access 层使用 stats-service.computeSignificance 计算(Welch's t-test + Cohen's d)。
|
||||
* 若提供则展示 p 值与效应量;否则回退到基于极差的经验规则。
|
||||
*/
|
||||
significance?: ClassComparisonSignificance | null
|
||||
}
|
||||
|
||||
type SignificanceLevel = "high" | "medium" | "low"
|
||||
|
||||
interface SignificanceResult {
|
||||
interface EmpiricalSignificanceResult {
|
||||
range: number
|
||||
level: SignificanceLevel
|
||||
topClass: ClassComparisonItem
|
||||
bottomClass: ClassComparisonItem
|
||||
}
|
||||
|
||||
/** 显著性判断阈值(经验规则,避免复杂统计计算) */
|
||||
/** 经验显著性判断阈值(在 significance prop 缺失时作为回退方案) */
|
||||
const MIN_SAMPLE_SIZE = 30
|
||||
const HIGH_RANGE_THRESHOLD = 10
|
||||
const MEDIUM_RANGE_THRESHOLD = 5
|
||||
|
||||
/**
|
||||
* v3-P3-5: 班级对比显著性分析。
|
||||
* 基于极差和样本量的经验规则判断班级间差异是否具有统计意义。
|
||||
* - 极差 >= 10 且各班样本量 >= 30:显著差异
|
||||
* - 极差 >= 5:可能存在差异(含极差大但样本不足的情况)
|
||||
* - 极差 < 5:差异不显著
|
||||
* v3-P3-5: 经验显著性分析(回退方案)。
|
||||
* 仅当 data-access 层未注入统计显著性结果时使用。
|
||||
*/
|
||||
function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult | null {
|
||||
function analyzeSignificance(data: ClassComparisonItem[]): EmpiricalSignificanceResult | null {
|
||||
if (data.length < 2) return null
|
||||
|
||||
let topClass = data[0]
|
||||
@@ -65,11 +68,37 @@ function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult |
|
||||
return { range, level, topClass, bottomClass }
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-5: 根据 Cohen's d 效应量映射到 UI 等级。
|
||||
* < 0.2 negligible → low
|
||||
* < 0.5 small → medium
|
||||
* >= 0.5 medium/large → high
|
||||
*/
|
||||
function effectSizeToLevel(effectSizeLabel: ClassComparisonSignificance["effectSizeLabel"]): SignificanceLevel {
|
||||
switch (effectSizeLabel) {
|
||||
case "negligible":
|
||||
return "low"
|
||||
case "small":
|
||||
return "medium"
|
||||
case "medium":
|
||||
case "large":
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
return score.toFixed(1)
|
||||
}
|
||||
|
||||
export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.Element {
|
||||
function formatPValue(p: number): string {
|
||||
if (p < 0.001) return "< 0.001"
|
||||
return p.toFixed(3)
|
||||
}
|
||||
|
||||
export function ClassComparisonChart({
|
||||
data,
|
||||
significance,
|
||||
}: ClassComparisonChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const isEmpty = !data || data.length === 0
|
||||
@@ -85,7 +114,21 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.E
|
||||
studentCount: d.studentCount,
|
||||
}))
|
||||
|
||||
const significance = isEmpty ? null : analyzeSignificance(data)
|
||||
// P3-5: 优先使用注入的统计显著性结果,回退到经验规则
|
||||
const empirical = isEmpty ? null : analyzeSignificance(data)
|
||||
const topClass = significance
|
||||
? data.find((d) => d.classId === significance.topClassId) ?? empirical?.topClass
|
||||
: empirical?.topClass
|
||||
const bottomClass = significance
|
||||
? data.find((d) => d.classId === significance.bottomClassId) ?? empirical?.bottomClass
|
||||
: empirical?.bottomClass
|
||||
|
||||
const level: SignificanceLevel | null = significance
|
||||
? effectSizeToLevel(significance.effectSizeLabel)
|
||||
: empirical?.level ?? null
|
||||
const range = topClass && bottomClass
|
||||
? topClass.averageScore - bottomClass.averageScore
|
||||
: null
|
||||
|
||||
const levelColorClass: Record<SignificanceLevel, string> = {
|
||||
high: "border-destructive/30 bg-destructive/5 text-destructive",
|
||||
@@ -132,28 +175,38 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.E
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
yWidth={36}
|
||||
heightClassName="h-[300px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
showLegend
|
||||
tooltipClassName="w-[240px]"
|
||||
tooltipClassName="w-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{significance ? (
|
||||
{level && topClass && bottomClass ? (
|
||||
<div
|
||||
className="mt-3 space-y-2"
|
||||
aria-label={t("classComparison.significanceAriaLabel", { level: levelLabel[significance.level] })}
|
||||
aria-label={t("classComparison.significanceAriaLabel", { level: levelLabel[level] })}
|
||||
>
|
||||
<div className={cn("flex items-start gap-2 rounded-md border p-3", levelColorClass[significance.level])}>
|
||||
<div className={cn("flex items-start gap-2 rounded-md border p-3", levelColorClass[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]}
|
||||
{t("classComparison.significanceTitle")} · {levelLabel[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>
|
||||
{range !== null && (
|
||||
<div className="text-xs opacity-90">
|
||||
{t("classComparison.significanceRange", { range: formatScore(range) })}
|
||||
</div>
|
||||
)}
|
||||
{significance ? (
|
||||
<div className="text-xs opacity-90">
|
||||
{t("classComparison.significanceStats", {
|
||||
pValue: formatPValue(significance.pValue),
|
||||
cohensD: significance.cohensD.toFixed(2),
|
||||
effectSize: t(`classComparison.effectSize.${significance.effectSizeLabel}`),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-xs opacity-80">{levelHint[level]}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,16 +227,25 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.E
|
||||
<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),
|
||||
name: topClass.className,
|
||||
score: formatScore(topClass.averageScore),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("classComparison.significanceBottomClass", {
|
||||
name: significance.bottomClass.className,
|
||||
score: formatScore(significance.bottomClass.averageScore),
|
||||
name: bottomClass.className,
|
||||
score: formatScore(bottomClass.averageScore),
|
||||
})}
|
||||
</div>
|
||||
{significance ? (
|
||||
<div className="pt-1 text-muted-foreground/70">
|
||||
{t("classComparison.significanceMethod", {
|
||||
significant: significance.isSignificant
|
||||
? t("classComparison.significantYes")
|
||||
: t("classComparison.significantNo"),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -21,7 +22,7 @@ interface ClassGradeReportProps {
|
||||
ranking: ClassRankingItem[]
|
||||
}
|
||||
|
||||
export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
|
||||
export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
74
src/modules/grades/components/draft-lock-banner.tsx
Normal file
74
src/modules/grades/components/draft-lock-banner.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { AlertTriangle, Lock, Loader2, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import type { DraftLockStatus } from "../data-access-drafts"
|
||||
|
||||
/**
|
||||
* P3-6 协同录入锁状态提示条。
|
||||
*
|
||||
* 三种状态:
|
||||
* 1. acquiring:获取中(loading)
|
||||
* 2. conflict:锁被他人持有(警告 + 重试按钮)
|
||||
* 3. acquired:已获取(轻量 tooltip 提示,不渲染 banner)
|
||||
*/
|
||||
interface DraftLockBannerProps {
|
||||
status: DraftLockStatus | null
|
||||
isAcquiring: boolean
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function DraftLockBanner({
|
||||
status,
|
||||
isAcquiring,
|
||||
onRetry,
|
||||
}: DraftLockBannerProps): JSX.Element | null {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
// 获取中
|
||||
if (isAcquiring) {
|
||||
return (
|
||||
<Alert className="border-blue-200 bg-blue-50">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
<AlertTitle className="text-blue-900">
|
||||
{t("collabLock.acquired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-blue-700">
|
||||
{t("collabLock.lockInfoTooltip")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
// 无状态或自己持锁:不显示
|
||||
if (!status || status.isMine) return null
|
||||
|
||||
// 锁被他人持有
|
||||
const teacherName = status.lockedByName ?? t("collabLock.lockConflictUnknownTeacher")
|
||||
|
||||
return (
|
||||
<Alert className="border-destructive/50 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
<AlertTitle>
|
||||
<Lock className="inline h-3 w-3 mr-1" aria-hidden="true" />
|
||||
{t("collabLock.lockConflictTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
{t("collabLock.lockConflictDescription", { teacherName })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-3 w-3" aria-hidden="true" />
|
||||
{t("collabLock.retryAcquire")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
518
src/modules/grades/components/excel-import-dialog.tsx
Normal file
518
src/modules/grades/components/excel-import-dialog.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useRef, useState } from "react"
|
||||
import { Upload, Download, FileSpreadsheet, Loader2, AlertCircle, CheckCircle2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { downloadBase64File } from "@/shared/lib/download"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
|
||||
import {
|
||||
downloadGradeImportTemplateAction,
|
||||
importGradesFromExcelAction,
|
||||
} from "../actions-import"
|
||||
import type { GradeImportResult } from "../import-export"
|
||||
|
||||
interface ExcelImportDialogProps {
|
||||
/** 班级 ID(必填) */
|
||||
classId: string
|
||||
/** 班级列表(供切换) */
|
||||
classes: Array<{ id: string; name: string }>
|
||||
/** 科目列表 */
|
||||
subjects: Array<{ id: string; name: string }>
|
||||
/** 触发按钮渲染函数 */
|
||||
trigger?: (open: () => void) => JSX.Element
|
||||
/** 导入成功后的回调(通常用于刷新列表) */
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
interface ImportFormState {
|
||||
classId: string
|
||||
subjectId: string
|
||||
title: string
|
||||
examId: string
|
||||
fullScore: string
|
||||
type: string
|
||||
semester: string
|
||||
}
|
||||
|
||||
const INITIAL_FORM: ImportFormState = {
|
||||
classId: "",
|
||||
subjectId: "",
|
||||
title: "",
|
||||
examId: "",
|
||||
fullScore: "100",
|
||||
type: "exam",
|
||||
semester: "1",
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 成绩 Excel 批量导入对话框。
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. 选择班级 → 自动下载模板(含学生姓名示例)
|
||||
* 2. 填写参数(科目、评估标题、满分等)
|
||||
* 3. 上传填好的 Excel 文件
|
||||
* 4. 点击"开始导入"调用 Server Action
|
||||
* 5. 显示导入结果(成功数 / 失败数 / 失败明细)
|
||||
*/
|
||||
export function ExcelImportDialog({
|
||||
classId: initialClassId,
|
||||
classes,
|
||||
subjects,
|
||||
trigger,
|
||||
onSuccess,
|
||||
}: ExcelImportDialogProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [form, setForm] = useState<ImportFormState>({
|
||||
...INITIAL_FORM,
|
||||
classId: initialClassId,
|
||||
subjectId: subjects[0]?.id ?? "",
|
||||
})
|
||||
const [result, setResult] = useState<GradeImportResult | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const reset = () => {
|
||||
setForm({ ...INITIAL_FORM, classId: initialClassId, subjectId: subjects[0]?.id ?? "" })
|
||||
setFile(null)
|
||||
setResult(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (!open) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
if (!form.classId) {
|
||||
toast.error(t("excelImport.errorSelectClass"))
|
||||
return
|
||||
}
|
||||
setIsDownloadingTemplate(true)
|
||||
const r = await safeActionCall(
|
||||
() => downloadGradeImportTemplateAction({ classId: form.classId }),
|
||||
{
|
||||
onError: () => toast.error(t("excelImport.errorTemplateDownload")),
|
||||
onFinally: () => setIsDownloadingTemplate(false),
|
||||
}
|
||||
)
|
||||
if (r?.success && r.data) {
|
||||
downloadBase64File(r.data.buffer, r.data.filename)
|
||||
toast.success(t("excelImport.templateDownloaded"))
|
||||
} else if (r) {
|
||||
toast.error(r.message ?? t("excelImport.errorTemplateDownload"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0] ?? null
|
||||
setFile(f)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
toast.error(t("excelImport.errorNoFile"))
|
||||
return
|
||||
}
|
||||
if (!form.classId || !form.subjectId || !form.title) {
|
||||
toast.error(t("excelImport.errorMissingFields"))
|
||||
return
|
||||
}
|
||||
|
||||
setIsImporting(true)
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("classId", form.classId)
|
||||
formData.append("subjectId", form.subjectId)
|
||||
formData.append("title", form.title)
|
||||
if (form.examId) formData.append("examId", form.examId)
|
||||
if (form.fullScore) formData.append("fullScore", form.fullScore)
|
||||
if (form.type) formData.append("type", form.type)
|
||||
if (form.semester) formData.append("semester", form.semester)
|
||||
|
||||
const r = await safeActionCall(
|
||||
() => importGradesFromExcelAction(null, formData),
|
||||
{
|
||||
onError: () => toast.error(t("excelImport.errorImport")),
|
||||
onFinally: () => setIsImporting(false),
|
||||
}
|
||||
)
|
||||
|
||||
if (r?.success && r.data) {
|
||||
setResult(r.data)
|
||||
if (r.data.failedCount === 0) {
|
||||
toast.success(t("excelImport.successAllImported", { count: r.data.successCount }))
|
||||
} else {
|
||||
toast.warning(t("excelImport.successPartial", {
|
||||
success: r.data.successCount,
|
||||
failed: r.data.failedCount,
|
||||
}))
|
||||
}
|
||||
onSuccess?.()
|
||||
} else if (r) {
|
||||
toast.error(r.message ?? t("excelImport.errorImport"))
|
||||
if (r.data) {
|
||||
setResult(r.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{trigger ? (
|
||||
<DialogTrigger asChild>
|
||||
{trigger(() => setIsOpen(true))}
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Upload className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("excelImport.triggerButton")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" aria-hidden="true" />
|
||||
{t("excelImport.dialogTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("excelImport.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 第一步:下载模板 */}
|
||||
<section className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/30">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{t("excelImport.step1Title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
|
||||
{t("excelImport.step1Description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDownloadTemplate}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isDownloadingTemplate || !form.classId}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDownloadingTemplate ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("excelImport.downloadTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 第二步:填写参数 */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("excelImport.step2Title")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ei-class" className="text-xs">
|
||||
{t("filters.class")} *
|
||||
</Label>
|
||||
<Select
|
||||
value={form.classId}
|
||||
onValueChange={(v) => setForm({ ...form, classId: v })}
|
||||
>
|
||||
<SelectTrigger id="ei-class">
|
||||
<SelectValue placeholder={t("filters.allClasses")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ei-subject" className="text-xs">
|
||||
{t("filters.subject")} *
|
||||
</Label>
|
||||
<Select
|
||||
value={form.subjectId}
|
||||
onValueChange={(v) => setForm({ ...form, subjectId: v })}
|
||||
>
|
||||
<SelectTrigger id="ei-subject">
|
||||
<SelectValue placeholder={t("filters.allSubjects")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label htmlFor="ei-title" className="text-xs">
|
||||
{t("excelImport.assessmentTitle")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="ei-title"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
placeholder={t("excelImport.assessmentTitlePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ei-fullscore" className="text-xs">
|
||||
{t("excelImport.fullScore")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ei-fullscore"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={form.fullScore}
|
||||
onChange={(e) => setForm({ ...form, fullScore: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ei-type" className="text-xs">
|
||||
{t("filters.type")}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(v) => setForm({ ...form, type: v })}
|
||||
>
|
||||
<SelectTrigger id="ei-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-1.5">
|
||||
<Label htmlFor="ei-semester" className="text-xs">
|
||||
{t("filters.semester")}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.semester}
|
||||
onValueChange={(v) => setForm({ ...form, semester: v })}
|
||||
>
|
||||
<SelectTrigger id="ei-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-1.5">
|
||||
<Label htmlFor="ei-examid" className="text-xs">
|
||||
{t("excelImport.examId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ei-examid"
|
||||
value={form.examId}
|
||||
onChange={(e) => setForm({ ...form, examId: e.target.value })}
|
||||
placeholder={t("excelImport.examIdPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 第三步:上传文件 */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("excelImport.step3Title")}
|
||||
</h3>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ei-file" className="text-xs">
|
||||
{t("excelImport.fileLabel")} *
|
||||
</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="ei-file"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("excelImport.fileHint")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 导入结果 */}
|
||||
{result ? (
|
||||
<section className="space-y-3 rounded-md border p-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{result.failedCount === 0 ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" aria-hidden="true" />
|
||||
)}
|
||||
{t("excelImport.resultTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("excelImport.resultSuccess")}
|
||||
</span>
|
||||
<span className="ml-2 font-semibold text-green-600">
|
||||
{result.successCount}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("excelImport.resultFailed")}
|
||||
</span>
|
||||
<span className="ml-2 font-semibold text-red-600">
|
||||
{result.failedCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.unmatchedStudents.length > 0 ? (
|
||||
<div className="text-xs">
|
||||
<p className="font-medium text-amber-700 dark:text-amber-300">
|
||||
{t("excelImport.unmatchedStudents")}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{result.unmatchedStudents.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.invalidRows.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">
|
||||
{t("excelImport.columnRow")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("excelImport.columnStudentName")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("excelImport.columnScore")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("excelImport.columnErrors")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.invalidRows.slice(0, 20).map((row) => (
|
||||
<TableRow key={row.row}>
|
||||
<TableCell className="font-mono">{row.row}</TableCell>
|
||||
<TableCell>{row.studentName || "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{row.score}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-red-600 dark:text-red-400">
|
||||
{row.errors.join("; ")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{result.invalidRows.length > 20 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-xs text-muted-foreground">
|
||||
{t("excelImport.moreErrors", {
|
||||
count: result.invalidRows.length - 20,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{t("excelImport.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isImporting || !file || !form.classId || !form.subjectId || !form.title}
|
||||
className="gap-2"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{isImporting
|
||||
? t("excelImport.importing")
|
||||
: t("excelImport.startImport")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
@@ -32,11 +33,11 @@ export function ExportButton({
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
label,
|
||||
}: ExportButtonProps) {
|
||||
}: ExportButtonProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExport = async (reportType: "detail" | "class") => {
|
||||
const handleExport = async (reportType: "detail" | "class"): Promise<void> => {
|
||||
if (!classId) {
|
||||
toast.error(t("export.selectClassFirst"))
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { PieChart as PieChartIcon } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -80,6 +81,13 @@ const PATTERN_DEFS = (
|
||||
|
||||
interface GradeDistributionChartProps {
|
||||
data: GradeDistributionResult | null
|
||||
/**
|
||||
* P3-9: 需要高亮的桶索引(用于学生视角的"你的位置"标注)。
|
||||
* -1 或 undefined 表示不高亮任何桶。
|
||||
*/
|
||||
highlightBucketIndex?: number
|
||||
/** P3-9: 学生归一化分数(用于显示"你的分数"标签) */
|
||||
studentScore?: number | null
|
||||
}
|
||||
|
||||
interface DistributionTooltipItem {
|
||||
@@ -106,19 +114,24 @@ function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPaylo
|
||||
)
|
||||
}
|
||||
|
||||
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
export function GradeDistributionChart({
|
||||
data,
|
||||
highlightBucketIndex,
|
||||
studentScore,
|
||||
}: GradeDistributionChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const isEmpty = !data || data.totalCount === 0
|
||||
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.buckets.map((b) => ({
|
||||
: data.buckets.map((b, i) => ({
|
||||
label: b.label,
|
||||
count: b.count,
|
||||
percentage:
|
||||
data.totalCount > 0
|
||||
? Math.round((b.count / data.totalCount) * 1000) / 10
|
||||
: 0,
|
||||
isHighlighted: highlightBucketIndex === i ? 1 : 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -149,9 +162,7 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
xTickFormatter={null}
|
||||
yAllowDecimals={false}
|
||||
yWidth={32}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
defs={PATTERN_DEFS}
|
||||
cellColors={BUCKET_FILLS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
@@ -169,6 +180,27 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{highlightBucketIndex !== undefined && highlightBucketIndex >= 0 && studentScore !== null && studentScore !== undefined && data ? (
|
||||
<div
|
||||
className="mt-3 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 p-2 text-xs"
|
||||
role="status"
|
||||
aria-label={t("distribution.yourPositionAriaLabel", {
|
||||
score: studentScore.toFixed(1),
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-primary">
|
||||
{t("distribution.yourPosition", {
|
||||
score: studentScore.toFixed(1),
|
||||
bucket: data.buckets[highlightBucketIndex]?.label ?? "",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -20,7 +21,7 @@ import type { SelectOption } from "../types"
|
||||
* v3-P2-1:subjects 改为通过 prop 传入,使用 subjectId 作为 value,
|
||||
* 而非硬编码科目名称。若未传入 subjects,则不显示科目筛选。
|
||||
*/
|
||||
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
|
||||
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
|
||||
@@ -49,7 +50,7 @@ export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
|
||||
<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">
|
||||
<SelectTrigger className="w-36 bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("filters.subject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -64,7 +65,7 @@ export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
|
||||
)}
|
||||
|
||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
|
||||
<SelectTrigger className="w-32 bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("filters.type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -77,7 +78,7 @@ export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
|
||||
</Select>
|
||||
|
||||
<Select value={semester} onValueChange={(val) => setSemester(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
|
||||
<SelectTrigger className="w-32 bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("filters.semester")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -13,7 +14,7 @@ interface GradeQueryFiltersProps {
|
||||
subjects: SelectOption[]
|
||||
}
|
||||
|
||||
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
|
||||
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
@@ -40,7 +41,7 @@ export function GradeRecordForm({
|
||||
students: SelectOption[]
|
||||
defaultClassId?: string
|
||||
defaultSubjectId?: string
|
||||
}) {
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
@@ -53,7 +54,7 @@ type EditableFields = {
|
||||
remark: string
|
||||
}
|
||||
|
||||
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
|
||||
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -211,8 +212,8 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* v4-P1-10: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
{/* v4-P1-10: 桌面端表格(md 及以上显示) */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<Table>
|
||||
<caption className="sr-only">{t("list.caption")}</caption>
|
||||
<TableHeader>
|
||||
@@ -257,7 +258,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
<TableCell>
|
||||
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
|
||||
</TableCell>
|
||||
<TableCell>S{r.semester}</TableCell>
|
||||
<TableCell>{t(`semester.s${r.semester}`)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
@@ -287,6 +288,74 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* P3-8: 移动端卡片堆叠视图(md 以下显示) */}
|
||||
<div
|
||||
className="block space-y-3 p-4 md:hidden"
|
||||
role="list"
|
||||
aria-label={t("list.caption")}
|
||||
>
|
||||
{records.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"
|
||||
role="listitem"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(r.id)}
|
||||
onCheckedChange={() => toggleRowSelection(r.id)}
|
||||
aria-label={t("list.selectRow", { name: r.studentName })}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{r.studentName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{r.className} · {r.subjectName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ScoreCell score={r.score} fullScore={r.fullScore} />
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5">
|
||||
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`semester.s${r.semester}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-t border-border/40 pt-2">
|
||||
<div className="text-sm">{r.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{r.recorderName} · {formatDate(r.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => startEdit(r)}
|
||||
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("edit.title")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("delete.title")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -5,7 +6,7 @@ import { StatItem } from "@/shared/components/ui/stat-item"
|
||||
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
|
||||
import type { GradeStats } from "../types"
|
||||
|
||||
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
|
||||
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!stats || stats.count === 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -24,7 +25,7 @@ interface GradeTrendCardProps {
|
||||
classAverageData?: ClassAverageTrendResult | null
|
||||
}
|
||||
|
||||
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps) {
|
||||
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
// v3-P3-4: 日期范围选择器,通过 URL 参数持久化
|
||||
const [trendRange, setTrendRange] = useQueryState(
|
||||
@@ -164,10 +165,10 @@ export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProp
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={series}
|
||||
heightClassName="h-[240px]"
|
||||
heightClassName="h-60"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={30}
|
||||
tooltipClassName="w-[200px]"
|
||||
tooltipClassName="w-52"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -12,7 +13,7 @@ interface GradeTrendChartProps {
|
||||
data: GradeTrendResult | null
|
||||
}
|
||||
|
||||
export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
export function GradeTrendChart({ data }: GradeTrendChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const isEmpty = !data || data.points.length === 0
|
||||
|
||||
@@ -54,10 +55,8 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
yWidth={36}
|
||||
tooltipClassName="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
|
||||
187
src/modules/grades/components/growth-archive-chart.tsx
Normal file
187
src/modules/grades/components/growth-archive-chart.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { TrendingUp, TrendingDown, Minus } 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 type { StudentGrowthArchiveResult } from "../types"
|
||||
|
||||
interface GrowthArchiveChartProps {
|
||||
/** 学生成长档案数据;为 null 时渲染空状态 */
|
||||
data: StudentGrowthArchiveResult | null
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-4: 学生纵向成长档案图表。
|
||||
*
|
||||
* 展示内容:
|
||||
* 1. 跨学年/学期的归一化平均分趋势线
|
||||
* 2. 总览统计:总记录数、总科目数、跨越学年数
|
||||
* 3. 成长趋势徽章:growthDelta > 0 显示"提升",< 0 显示"下降",= 0 显示"持平"
|
||||
*
|
||||
* 隐私保护:仅显示聚合并的均分,不暴露个人排名或他人分数。
|
||||
*/
|
||||
export function GrowthArchiveChart({ data }: GrowthArchiveChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
const { chartData, hasData, GrowthIcon, growthLabel, growthTone } = useMemo(() => {
|
||||
if (!data || data.points.length === 0) {
|
||||
return {
|
||||
chartData: [],
|
||||
hasData: false,
|
||||
GrowthIcon: Minus,
|
||||
growthLabel: "",
|
||||
growthTone: "muted" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const points = data.points.map((p) => ({
|
||||
label: `${p.academicYearName} · ${t(`semester.s${p.semester}`)}`,
|
||||
fullTitle: `${p.academicYearName} · ${t(`semester.s${p.semester}`)}`,
|
||||
averageScore: p.averageScore,
|
||||
passRate: p.passRate,
|
||||
excellentRate: p.excellentRate,
|
||||
recordCount: p.recordCount,
|
||||
subjectCount: p.subjectCount,
|
||||
}))
|
||||
|
||||
const delta = data.growthDelta
|
||||
let Icon = Minus
|
||||
let tone: "up" | "down" | "muted" = "muted"
|
||||
let label = t("growthArchive.deltaStable")
|
||||
if (delta > 0) {
|
||||
Icon = TrendingUp
|
||||
tone = "up"
|
||||
label = t("growthArchive.deltaUp", { delta: delta.toFixed(1) })
|
||||
} else if (delta < 0) {
|
||||
Icon = TrendingDown
|
||||
tone = "down"
|
||||
label = t("growthArchive.deltaDown", { delta: Math.abs(delta).toFixed(1) })
|
||||
}
|
||||
|
||||
return {
|
||||
chartData: points,
|
||||
hasData: true,
|
||||
GrowthIcon: Icon,
|
||||
growthLabel: label,
|
||||
growthTone: tone,
|
||||
}
|
||||
}, [data, t])
|
||||
|
||||
const series = [
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: t("growthArchive.averageScore"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title={t("growthArchive.title")}
|
||||
description={
|
||||
hasData && data
|
||||
? t("growthArchive.description", {
|
||||
years: data.totalAcademicYears,
|
||||
records: data.totalRecords,
|
||||
subjects: data.totalSubjects,
|
||||
})
|
||||
: t("growthArchive.descriptionEmpty")
|
||||
}
|
||||
icon={TrendingUp}
|
||||
iconClassName="text-primary"
|
||||
isEmpty={!hasData}
|
||||
emptyTitle={t("growthArchive.emptyTitle")}
|
||||
emptyDescription={t("growthArchive.emptyDescription")}
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
{hasData && data ? (
|
||||
<div className="space-y-4">
|
||||
{/* 成长趋势徽章 */}
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 p-3"
|
||||
role="status"
|
||||
aria-label={growthLabel}
|
||||
>
|
||||
<GrowthIcon
|
||||
className={
|
||||
growthTone === "up"
|
||||
? "h-5 w-5 text-emerald-600"
|
||||
: growthTone === "down"
|
||||
? "h-5 w-5 text-destructive"
|
||||
: "h-5 w-5 text-muted-foreground"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
growthTone === "up"
|
||||
? "text-sm font-medium text-emerald-700"
|
||||
: growthTone === "down"
|
||||
? "text-sm font-medium text-destructive"
|
||||
: "text-sm font-medium text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{growthLabel}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{t("growthArchive.overallAverage", { score: data.overallAverage.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 趋势折线图 */}
|
||||
<div
|
||||
className="rounded-md border bg-card p-4"
|
||||
role="img"
|
||||
aria-label={
|
||||
hasData
|
||||
? t("growthArchive.ariaLabelNonEmpty", { count: data.points.length })
|
||||
: t("growthArchive.ariaLabelEmpty")
|
||||
}
|
||||
>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={series}
|
||||
heightClassName="h-64"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={30}
|
||||
tooltipClassName="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 详细统计表(a11y sr-only + 可视化卡片) */}
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3" role="list">
|
||||
{data.points.map((p) => (
|
||||
<li
|
||||
key={`${p.academicYearId}-${p.semester}`}
|
||||
className="rounded-md border bg-background p-3 text-xs"
|
||||
role="listitem"
|
||||
>
|
||||
<div className="font-medium text-foreground">
|
||||
{p.academicYearName} · {t(`semester.s${p.semester}`)}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{t("growthArchive.statsAverage", { score: p.averageScore.toFixed(1) })}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("growthArchive.statsPassRate", { rate: p.passRate.toFixed(0) })}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("growthArchive.statsRecords", {
|
||||
records: p.recordCount,
|
||||
subjects: p.subjectCount,
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
232
src/modules/grades/components/knowledge-point-mastery-chart.tsx
Normal file
232
src/modules/grades/components/knowledge-point-mastery-chart.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { Brain, ArrowRight } 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 { Button } from "@/shared/components/ui/button"
|
||||
import type { KnowledgePointStat } from "@/modules/diagnostic/types"
|
||||
|
||||
interface KnowledgePointMasteryChartProps {
|
||||
/** 知识点掌握度统计数据;为空数组时渲染空状态 */
|
||||
data: KnowledgePointStat[]
|
||||
/** 链接到的诊断详情页路径(如 "/teacher/diagnostic") */
|
||||
detailHref?: string
|
||||
}
|
||||
|
||||
/** P3-3: 知识点掌握度颜色映射(与 diagnostic 模块 masteryColor 对齐)。 */
|
||||
function masteryBarColor(level: number): string {
|
||||
if (level >= 80) return "hsl(142, 71%, 45%)" // green
|
||||
if (level >= 60) return "hsl(43, 96%, 56%)" // yellow
|
||||
if (level >= 40) return "hsl(25, 95%, 53%)" // orange
|
||||
return "hsl(0, 84%, 60%)" // red
|
||||
}
|
||||
|
||||
interface MasteryTooltipItem {
|
||||
label: string
|
||||
averageMastery: number
|
||||
masteredCount: number
|
||||
notMasteredCount: number
|
||||
totalStudents: number
|
||||
}
|
||||
|
||||
interface MasteryTooltipPayload {
|
||||
payload?: MasteryTooltipItem
|
||||
}
|
||||
|
||||
function isMasteryTooltipPayload(v: unknown): v is MasteryTooltipPayload {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
const obj = v as Record<string, unknown>
|
||||
const inner = obj.payload
|
||||
if (inner === undefined || inner === null) return true
|
||||
if (typeof inner !== "object") return false
|
||||
const item = inner as Record<string, unknown>
|
||||
return (
|
||||
typeof item.label === "string" &&
|
||||
typeof item.averageMastery === "number" &&
|
||||
typeof item.masteredCount === "number" &&
|
||||
typeof item.notMasteredCount === "number" &&
|
||||
typeof item.totalStudents === "number"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-3: 知识点掌握度图表(集成 diagnostic 模块到 grades analytics 页面)。
|
||||
*
|
||||
* 展示内容:
|
||||
* 1. 按掌握度升序排列的知识点柱状图(薄弱点优先突出)
|
||||
* 2. 颜色编码:≥80 绿色(优秀)、60-79 黄色(良好)、40-59 橙色(待提升)、<40 红色(薄弱)
|
||||
* 3. 前 3 个薄弱知识点摘要
|
||||
* 4. 跳转到完整诊断页的链接
|
||||
*
|
||||
* 数据来源:调用 diagnostic.data-access.getClassMasterySummary 获取 KnowledgePointStat[]
|
||||
*/
|
||||
export function KnowledgePointMasteryChart({
|
||||
data,
|
||||
detailHref,
|
||||
}: KnowledgePointMasteryChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
const { chartData, sortedStats, weakPoints, averageMastery, cellColors } = useMemo(() => {
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
chartData: [],
|
||||
sortedStats: [],
|
||||
weakPoints: [],
|
||||
averageMastery: 0,
|
||||
cellColors: {} as Record<string, string>,
|
||||
}
|
||||
}
|
||||
|
||||
// 按掌握度升序排列(薄弱点优先)
|
||||
const sorted = [...data].sort((a, b) => a.averageMastery - b.averageMastery)
|
||||
const avg = Math.round(
|
||||
(sorted.reduce((sum, s) => sum + s.averageMastery, 0) / sorted.length) * 100
|
||||
) / 100
|
||||
|
||||
// 柱状图数据
|
||||
const points = sorted.map((s) => ({
|
||||
label: s.knowledgePointName,
|
||||
averageMastery: s.averageMastery,
|
||||
masteredCount: s.masteredCount,
|
||||
notMasteredCount: s.notMasteredCount,
|
||||
totalStudents: s.totalStudents,
|
||||
}))
|
||||
|
||||
// 单元格颜色映射
|
||||
const colors: Record<string, string> = {}
|
||||
for (const s of sorted) {
|
||||
colors[s.knowledgePointName] = masteryBarColor(s.averageMastery)
|
||||
}
|
||||
|
||||
// 前 3 个薄弱点(掌握度 < 80)
|
||||
const weak = sorted.filter((s) => s.averageMastery < 80).slice(0, 3)
|
||||
|
||||
return {
|
||||
chartData: points,
|
||||
sortedStats: sorted,
|
||||
weakPoints: weak,
|
||||
averageMastery: avg,
|
||||
cellColors: colors,
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const hasData = chartData.length > 0
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title={t("knowledgePointMastery.title")}
|
||||
description={
|
||||
hasData
|
||||
? t("knowledgePointMastery.description", {
|
||||
count: sortedStats.length,
|
||||
avg: averageMastery.toFixed(1),
|
||||
})
|
||||
: t("knowledgePointMastery.descriptionEmpty")
|
||||
}
|
||||
icon={Brain}
|
||||
iconClassName="text-primary"
|
||||
isEmpty={!hasData}
|
||||
emptyTitle={t("knowledgePointMastery.emptyTitle")}
|
||||
emptyDescription={t("knowledgePointMastery.emptyDescription")}
|
||||
emptyClassName="h-60"
|
||||
action={
|
||||
detailHref ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={detailHref}>
|
||||
{t("knowledgePointMastery.viewDetail")}
|
||||
<ArrowRight className="ml-1 h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{hasData ? (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t("knowledgePointMastery.ariaLabel", { count: sortedStats.length })}
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "averageMastery",
|
||||
name: t("knowledgePointMastery.averageMastery"),
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
xKey="label"
|
||||
yDomain={[0, 100]}
|
||||
yAllowDecimals={false}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
xTickFormatter="default"
|
||||
xTruncateLength={6}
|
||||
yWidth={32}
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
cellColors={cellColors}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
if (!isMasteryTooltipPayload(payload)) return null
|
||||
const item = payload.payload
|
||||
if (!item) return null
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("knowledgePointMastery.tooltipMastery", { score: item.averageMastery.toFixed(1) })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("knowledgePointMastery.tooltipStudents", {
|
||||
mastered: item.masteredCount,
|
||||
total: item.totalStudents,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 薄弱知识点摘要 */}
|
||||
{weakPoints.length > 0 ? (
|
||||
<div
|
||||
className="rounded-md border border-orange-200 bg-orange-50 p-3 dark:border-orange-900 dark:bg-orange-950/30"
|
||||
role="status"
|
||||
aria-label={t("knowledgePointMastery.weakPointsAriaLabel")}
|
||||
>
|
||||
<div className="text-xs font-medium text-orange-700 dark:text-orange-300">
|
||||
{t("knowledgePointMastery.weakPointsTitle")}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1" role="list">
|
||||
{weakPoints.map((wp) => (
|
||||
<li
|
||||
key={wp.knowledgePointId}
|
||||
className="flex items-center justify-between text-xs"
|
||||
role="listitem"
|
||||
>
|
||||
<span className="text-foreground">{wp.knowledgePointName}</span>
|
||||
<span
|
||||
className={
|
||||
wp.averageMastery < 40
|
||||
? "font-medium text-red-600 dark:text-red-400"
|
||||
: wp.averageMastery < 60
|
||||
? "font-medium text-orange-600 dark:text-orange-400"
|
||||
: "font-medium text-yellow-600 dark:text-yellow-400"
|
||||
}
|
||||
>
|
||||
{wp.averageMastery.toFixed(1)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -15,7 +16,7 @@ import type { RankingTrendResult } from "../types"
|
||||
* v3-P1-3:学生页面显示排名趋势,Y 轴反转(第 1 名在顶部)。
|
||||
* 对比同类 K12 系统(PowerSchool、Infinite Campus 等)的排名趋势功能。
|
||||
*/
|
||||
export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }) {
|
||||
export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -67,10 +68,10 @@ export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }
|
||||
]}
|
||||
yDomain={[Math.max(maxRank, 1), 1]}
|
||||
yTickFormatter={(value: number) => `#${value}`}
|
||||
heightClassName="h-[240px]"
|
||||
heightClassName="h-60"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={40}
|
||||
tooltipClassName="w-[200px]"
|
||||
tooltipClassName="w-52"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
|
||||
48
src/modules/grades/components/report-card-print-action.tsx
Normal file
48
src/modules/grades/components/report-card-print-action.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { Printer, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
/**
|
||||
* P3-1: 报告卡打印按钮(客户端组件)。
|
||||
*
|
||||
* 渲染在报告卡页面顶部,点击后调用浏览器原生 window.print()。
|
||||
* 通过 globals.css 中的 @media print 规则实现 A4 打印布局。
|
||||
*/
|
||||
export function ReportCardPrintAction(): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const [isPreparing, setIsPreparing] = useState(false)
|
||||
|
||||
const handlePrint = () => {
|
||||
if (typeof window === "undefined") return
|
||||
setIsPreparing(true)
|
||||
try {
|
||||
window.print()
|
||||
} catch {
|
||||
toast.error(t("reportCard.errorPrint"))
|
||||
} finally {
|
||||
// 等待一帧后重置状态,确保用户感知到"准备中"反馈
|
||||
window.requestAnimationFrame(() => {
|
||||
setIsPreparing(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handlePrint} disabled={isPreparing} className="gap-2">
|
||||
{isPreparing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Printer className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{isPreparing
|
||||
? t("reportCard.preparing")
|
||||
: t("reportCard.print")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
106
src/modules/grades/components/report-card-print-button.tsx
Normal file
106
src/modules/grades/components/report-card-print-button.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
|
||||
interface ReportCardPrintButtonProps {
|
||||
/** 学生 ID */
|
||||
studentId: string
|
||||
/** 可选的学年列表 */
|
||||
academicYears: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-1: 成绩报告卡打印按钮(客户端组件)。
|
||||
*
|
||||
* 该组件不直接调用 window.print(),而是通过路由跳转到
|
||||
* /teacher/grades/report-card?studentId=xxx&academicYearId=yyy&semester=zzz
|
||||
* 由报告卡页面渲染完整 A4 视图后,用户点击页面上的"打印"按钮触发浏览器打印。
|
||||
*
|
||||
* 这种方式的好处:
|
||||
* 1. 报告卡页面是独立路由,可被书签收藏、家长直接访问
|
||||
* 2. 打印样式仅在该页面生效,避免污染其他页面
|
||||
* 3. 路由层完成权限校验,避免客户端绕过
|
||||
*/
|
||||
export function ReportCardPrintButton({
|
||||
studentId,
|
||||
academicYears,
|
||||
}: ReportCardPrintButtonProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [academicYearId, setAcademicYearId] = useState<string>("all")
|
||||
const [semester, setSemester] = useState<string>("all")
|
||||
|
||||
const handleNavigate = () => {
|
||||
startTransition(() => {
|
||||
const params = new URLSearchParams()
|
||||
params.set("studentId", studentId)
|
||||
if (academicYearId !== "all") params.set("academicYearId", academicYearId)
|
||||
if (semester !== "all") params.set("semester", semester)
|
||||
try {
|
||||
router.push(`/teacher/grades/report-card?${params.toString()}`)
|
||||
} catch {
|
||||
toast.error(t("reportCard.errorNavigate"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-academic-year" className="text-xs">
|
||||
{t("reportCard.filterAcademicYear")}
|
||||
</Label>
|
||||
<Select value={academicYearId} onValueChange={setAcademicYearId}>
|
||||
<SelectTrigger id="rc-academic-year" className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("reportCard.filterAllAcademicYears")}</SelectItem>
|
||||
{academicYears.map((y) => (
|
||||
<SelectItem key={y.id} value={y.id}>
|
||||
{y.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-semester" className="text-xs">
|
||||
{t("reportCard.filterSemester")}
|
||||
</Label>
|
||||
<Select value={semester} onValueChange={setSemester}>
|
||||
<SelectTrigger id="rc-semester" className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("reportCard.filterAllSemesters")}</SelectItem>
|
||||
<SelectItem value="1">{t("semester.s1")}</SelectItem>
|
||||
<SelectItem value="2">{t("semester.s2")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleNavigate} disabled={isPending} className="gap-2">
|
||||
<Printer className="h-4 w-4" aria-hidden="true" />
|
||||
{t("reportCard.openReportCard")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
323
src/modules/grades/components/report-card-view.tsx
Normal file
323
src/modules/grades/components/report-card-view.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { JSX } from "react"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
import type { ReportCardData, ReportCardSubjectItem } from "../lib/report-card"
|
||||
import type { GradeRecordType } from "../types"
|
||||
|
||||
interface ReportCardViewProps {
|
||||
data: ReportCardData
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
return Number.isFinite(score) ? score.toFixed(1) : "—"
|
||||
}
|
||||
|
||||
function formatRank(rank: number, total: number): string {
|
||||
if (rank <= 0 || total <= 0) return "—"
|
||||
return `${rank} / ${total}`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const dd = String(d.getDate()).padStart(2, "0")
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(
|
||||
type: GradeRecordType,
|
||||
t: Awaited<ReturnType<typeof getTranslations<"grades">>>
|
||||
): string {
|
||||
const key = `type.${type}` as const
|
||||
return t(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-1: 成绩报告卡视图组件。
|
||||
*
|
||||
* 该组件渲染为纯服务器组件,输出符合 A4 打印规范的 HTML。
|
||||
* 打印样式通过 report-card-print.css 全局加载(@media print 规则)。
|
||||
*
|
||||
* 布局:
|
||||
* 1. 顶部:学校名 + "学期成绩报告卡" 标题 + 学生基本信息
|
||||
* 2. 中部:各科目成绩明细表格(科目 | 评估 | 类型 | 得分 | 排名 | 备注)
|
||||
* 3. 底部:综合统计 + 教师评语区 + 签名区 + 生成时间
|
||||
*/
|
||||
export async function ReportCardView({
|
||||
data,
|
||||
}: ReportCardViewProps): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
return (
|
||||
<div
|
||||
className="report-card mx-auto w-[210mm] min-h-[297mm] bg-white p-12 text-black shadow-lg"
|
||||
aria-label={t("reportCard.ariaLabel", { name: data.studentName })}
|
||||
>
|
||||
{/* 顶部:标题 + 学校信息 */}
|
||||
<header className="report-card-header flex flex-col items-center gap-3 pb-6 border-b-2 border-black">
|
||||
<div className="flex items-center gap-3">
|
||||
<GraduationCap className="h-8 w-8" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("reportCard.schoolName")}
|
||||
</h1>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("reportCard.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
{t("reportCard.periodLabel", {
|
||||
year: data.academicYearName ?? "—",
|
||||
semester:
|
||||
data.semester === "1"
|
||||
? t("semester.s1")
|
||||
: data.semester === "2"
|
||||
? t("semester.s2")
|
||||
: t("reportCard.allSemesters"),
|
||||
})}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 学生基本信息 */}
|
||||
<section className="report-card-info py-4">
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<dt className="w-24 font-medium text-gray-700">
|
||||
{t("reportCard.studentName")}
|
||||
</dt>
|
||||
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
|
||||
{data.studentName}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-24 font-medium text-gray-700">
|
||||
{t("reportCard.className")}
|
||||
</dt>
|
||||
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
|
||||
{data.className}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-24 font-medium text-gray-700">
|
||||
{t("reportCard.classTeacher")}
|
||||
</dt>
|
||||
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
|
||||
{data.classTeacherName}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<dt className="w-24 font-medium text-gray-700">
|
||||
{t("reportCard.generatedAt")}
|
||||
</dt>
|
||||
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
|
||||
{formatDate(data.generatedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<Separator className="my-4 bg-gray-300" />
|
||||
|
||||
{/* 成绩明细表格 */}
|
||||
<section className="report-card-grades py-2">
|
||||
<h3 className="mb-3 text-base font-semibold">
|
||||
{t("reportCard.gradesSectionTitle")}
|
||||
</h3>
|
||||
{data.subjects.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 italic">
|
||||
{t("reportCard.emptyGrades")}
|
||||
</p>
|
||||
) : (
|
||||
<Table className="report-card-table border border-gray-400">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-gray-400 bg-gray-100">
|
||||
<TableHead className="w-32 border-r border-gray-300 text-black">
|
||||
{t("reportCard.columnSubject")}
|
||||
</TableHead>
|
||||
<TableHead className="border-r border-gray-300 text-black">
|
||||
{t("reportCard.columnAssessment")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20 border-r border-gray-300 text-black">
|
||||
{t("reportCard.columnType")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20 border-r border-gray-300 text-right text-black">
|
||||
{t("reportCard.columnScore")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24 border-r border-gray-300 text-right text-black">
|
||||
{t("reportCard.columnRank")}
|
||||
</TableHead>
|
||||
<TableHead className="w-32 text-black">
|
||||
{t("reportCard.columnRemark")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.subjects.flatMap((subject, idx) =>
|
||||
renderSubjectRows(subject, idx, t)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 综合统计 */}
|
||||
<section className="report-card-summary py-4">
|
||||
<h3 className="mb-3 text-base font-semibold">
|
||||
{t("reportCard.summarySectionTitle")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<SummaryCell
|
||||
label={t("reportCard.overallAverage")}
|
||||
value={`${formatScore(data.overallAverage)}`}
|
||||
/>
|
||||
<SummaryCell
|
||||
label={t("reportCard.overallRank")}
|
||||
value={formatRank(data.overallRank, data.classTotalStudents)}
|
||||
/>
|
||||
<SummaryCell
|
||||
label={t("reportCard.passRate")}
|
||||
value={`${data.overallPassRate.toFixed(1)}%`}
|
||||
/>
|
||||
<SummaryCell
|
||||
label={t("reportCard.excellentRate")}
|
||||
value={`${data.overallExcellentRate.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 教师评语 */}
|
||||
<section className="report-card-comments py-4">
|
||||
<h3 className="mb-2 text-base font-semibold">
|
||||
{t("reportCard.commentsTitle")}
|
||||
</h3>
|
||||
<div
|
||||
className="h-24 border border-gray-300 p-2 text-sm text-gray-500 italic"
|
||||
role="textbox"
|
||||
aria-label={t("reportCard.commentsAriaLabel")}
|
||||
>
|
||||
{t("reportCard.commentsPlaceholder")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 签名区 */}
|
||||
<section className="report-card-signatures grid grid-cols-3 gap-6 pt-8">
|
||||
<SignatureArea label={t("reportCard.signatureClassTeacher")} />
|
||||
<SignatureArea label={t("reportCard.signatureParent")} />
|
||||
<SignatureArea label={t("reportCard.signaturePrincipal")} />
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="report-card-footer pt-6 text-center text-xs text-gray-500">
|
||||
<p>
|
||||
{t("reportCard.footerNote", { date: formatDate(data.generatedAt) })}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCell({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 border border-gray-300 bg-gray-50 p-2 text-center">
|
||||
<span className="text-xs text-gray-600">{label}</span>
|
||||
<span className="text-lg font-semibold text-black">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignatureArea({ label }: { label: string }): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-12 w-32 border-b border-dotted border-gray-500" />
|
||||
<span className="text-xs text-gray-700">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSubjectRows(
|
||||
subject: ReportCardSubjectItem,
|
||||
index: number,
|
||||
t: Awaited<ReturnType<typeof getTranslations<"grades">>>
|
||||
): JSX.Element[] {
|
||||
if (subject.records.length === 0) {
|
||||
return [
|
||||
<TableRow key={`${subject.subjectId}-${index}`} className="border-b border-gray-300">
|
||||
<TableCell className="border-r border-gray-200 font-medium">
|
||||
{subject.subjectName}
|
||||
</TableCell>
|
||||
<TableCell className="border-r border-gray-200 text-gray-500 italic">
|
||||
{t("reportCard.noRecords")}
|
||||
</TableCell>
|
||||
<TableCell className="border-r border-gray-200">—</TableCell>
|
||||
<TableCell className="border-r border-gray-200 text-right">—</TableCell>
|
||||
<TableCell className="border-r border-gray-200 text-right">
|
||||
{formatRank(subject.rankInSubject, subject.totalStudentsInSubject)}
|
||||
</TableCell>
|
||||
<TableCell>—</TableCell>
|
||||
</TableRow>,
|
||||
]
|
||||
}
|
||||
|
||||
return subject.records.map((record, rIdx) => (
|
||||
<TableRow
|
||||
key={`${subject.subjectId}-${rIdx}`}
|
||||
className="border-b border-gray-200"
|
||||
>
|
||||
{rIdx === 0 ? (
|
||||
<TableCell
|
||||
className="border-r border-gray-200 font-medium align-top"
|
||||
rowSpan={subject.records.length}
|
||||
>
|
||||
{subject.subjectName}
|
||||
<span className="block text-xs text-gray-500 mt-1">
|
||||
{t("reportCard.subjectAvg", { score: formatScore(subject.averageScore) })}
|
||||
</span>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="border-r border-gray-200">{record.title}</TableCell>
|
||||
<TableCell className="border-r border-gray-200 text-xs">
|
||||
{typeLabel(record.type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="border-r border-gray-200 text-right font-mono">
|
||||
{formatScore(record.score)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{" / "}
|
||||
{formatScore(record.fullScore)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{rIdx === 0 ? (
|
||||
<TableCell
|
||||
className="border-r border-gray-200 text-right align-top"
|
||||
rowSpan={subject.records.length}
|
||||
>
|
||||
{formatRank(subject.rankInSubject, subject.totalStudentsInSubject)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-xs text-gray-600">
|
||||
{record.remark ?? ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { School, TrendingUp, CheckCircle2, Award } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
@@ -11,7 +12,10 @@ interface SchoolWideSummaryCardProps {
|
||||
summary: SchoolWideGradeSummary
|
||||
}
|
||||
|
||||
export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps): JSX.Element {
|
||||
export async function SchoolWideSummaryCard({
|
||||
summary,
|
||||
}: SchoolWideSummaryCardProps): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
const { totals, grades } = summary
|
||||
|
||||
return (
|
||||
@@ -20,48 +24,53 @@ export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps):
|
||||
<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" />
|
||||
<CardTitle className="text-sm font-medium">{t("schoolWide.gradeCount")}</CardTitle>
|
||||
<School className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{totals.gradeCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totals.classCount} 个班级 · {totals.studentCount} 名学生
|
||||
{t("schoolWide.classStudentInfo", {
|
||||
classCount: totals.classCount,
|
||||
studentCount: 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" />
|
||||
<CardTitle className="text-sm font-medium">{t("schoolWide.averageScore")}</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{formatNumber(totals.averageScore)}</div>
|
||||
<p className="text-xs text-muted-foreground">基于 {totals.recordCount} 条成绩记录</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("schoolWide.recordCount", { count: 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" />
|
||||
<CardTitle className="text-sm font-medium">{t("schoolWide.passRate")}</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground">{t("schoolWide.passLine")}</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" />
|
||||
<CardTitle className="text-sm font-medium">{t("schoolWide.excellentRate")}</CardTitle>
|
||||
<Award className="h-4 w-4 text-amber-600" aria-hidden="true" />
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground">{t("schoolWide.excellentLine")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -69,24 +78,27 @@ export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps):
|
||||
{/* 各年级对比表格 */}
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">各年级成绩对比</CardTitle>
|
||||
<CardDescription>按年级聚合的平均分、及格率与优秀率对比。</CardDescription>
|
||||
<CardTitle className="text-base">{t("schoolWide.comparisonTitle")}</CardTitle>
|
||||
<CardDescription>{t("schoolWide.comparisonDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{grades.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">暂无年级成绩数据</p>
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("schoolWide.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<caption className="sr-only">{t("schoolWide.comparisonTitle")}</caption>
|
||||
<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>
|
||||
<TableHead>{t("schoolWide.columns.schoolGrade")}</TableHead>
|
||||
<TableHead className="text-right">{t("schoolWide.columns.classCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("schoolWide.columns.studentCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("schoolWide.columns.recordCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("schoolWide.columns.average")}</TableHead>
|
||||
<TableHead className="text-right">{t("schoolWide.columns.passRate")}</TableHead>
|
||||
<TableHead className="text-right">{t("schoolWide.columns.excellentRate")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -17,7 +18,7 @@ import { GraduationCap, Trophy } from "lucide-react"
|
||||
import type { StudentGradeSummary } from "../types"
|
||||
import { GRADE_TYPE_VARIANT } from "../types"
|
||||
|
||||
export async function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
|
||||
export async function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }): Promise<JSX.Element> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (!summary) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { Radar } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -11,7 +12,7 @@ interface SubjectComparisonChartProps {
|
||||
data: SubjectComparisonItem[]
|
||||
}
|
||||
|
||||
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
@@ -46,7 +47,6 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
angleTickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
heightClassName="h-[300px]"
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* Grades/Diagnostic 模块通用 Widget 边界组件。
|
||||
*
|
||||
* 组合三个能力:
|
||||
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
|
||||
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
|
||||
* 3. Skeleton — 与 Widget 尺寸匹配的占位
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <WidgetBoundary title="成绩趋势">
|
||||
* <GradeTrendChart data={data} />
|
||||
* </WidgetBoundary>
|
||||
* ```
|
||||
*/
|
||||
|
||||
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"
|
||||
|
||||
interface WidgetBoundaryProps {
|
||||
children: ReactNode
|
||||
/** Widget 标题(用于错误提示和 aria-label) */
|
||||
title?: string
|
||||
/** 骨架屏高度(默认 200px) */
|
||||
skeletonHeight?: number
|
||||
/** 自定义错误描述 */
|
||||
fallbackDescription?: string
|
||||
/** 重试按钮文案 */
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
interface WidgetBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
interface WidgetErrorBoundaryProps {
|
||||
title: string
|
||||
fallbackDescription: string
|
||||
retryLabel: string
|
||||
loadFailedMessage: string
|
||||
retryAriaLabel: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
class WidgetErrorBoundary extends Component<
|
||||
WidgetErrorBoundaryProps,
|
||||
WidgetBoundaryState
|
||||
> {
|
||||
constructor(props: WidgetErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): WidgetBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{this.props.loadFailedMessage}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.fallbackDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
aria-label={this.props.retryAriaLabel}
|
||||
>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetSkeleton({
|
||||
height,
|
||||
loadingAriaLabel,
|
||||
}: {
|
||||
height: number
|
||||
loadingAriaLabel: string
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={loadingAriaLabel}
|
||||
aria-live="polite"
|
||||
className="space-y-3 p-4"
|
||||
style={{ minHeight: height }}
|
||||
>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WidgetBoundary({
|
||||
children,
|
||||
title,
|
||||
skeletonHeight = 200,
|
||||
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={effectiveTitle}
|
||||
fallbackDescription={effectiveFallbackDescription}
|
||||
retryLabel={effectiveRetryLabel}
|
||||
loadFailedMessage={loadFailedMessage}
|
||||
retryAriaLabel={retryAriaLabel}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</WidgetErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -8,27 +8,37 @@ import { gradeRecords } from "@/shared/db/schema"
|
||||
import {
|
||||
getClassesByGradeId,
|
||||
getClassNameById,
|
||||
getStudentActiveClassId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getGrades, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getAcademicYears, getGrades, getSubjectOptions } from "@/modules/school/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 {
|
||||
buildGradeTrendPoints,
|
||||
buildGrowthArchivePoints,
|
||||
computeAverageScore,
|
||||
computeClassComparisonStats,
|
||||
computeGradeDistribution,
|
||||
computeGradeStats,
|
||||
computeSignificance,
|
||||
computeSubjectComparisonStats,
|
||||
computeTrendAverage,
|
||||
findBucketIndex,
|
||||
} from "./stats-service"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
ClassComparisonResult,
|
||||
ClassComparisonSignificance,
|
||||
GradeDistributionByGradeResult,
|
||||
GradeDistributionResult,
|
||||
GradeDistributionWithPosition,
|
||||
GradeTrendResult,
|
||||
SchoolWideGradeSummary,
|
||||
SchoolWideGradeSummaryItem,
|
||||
StudentGrowthArchiveResult,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
@@ -163,6 +173,303 @@ export const getClassComparison = cache(
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* P3-5: 获取班级对比数据 + 班级间统计显著性分析结果。
|
||||
*
|
||||
* 显著性分析流程:
|
||||
* 1. 复用 getClassComparison 获取各班级聚合 stats
|
||||
* 2. 找到均分最高与最低班级
|
||||
* 3. 从 allRows 中提取这两个班级的归一化分数数组
|
||||
* 4. 调用 stats-service.computeSignificance 计算 Cohen's d 与 p 值
|
||||
*
|
||||
* 返回的 significance 为 null 的场景:
|
||||
* - 班级数 < 2
|
||||
* - 任意班级样本量 < 2
|
||||
* - 方差为 0(所有分数相同)
|
||||
*
|
||||
* 该函数复用 getClassComparison 的 cache(参数一致),不会触发额外 DB 查询。
|
||||
*/
|
||||
export const getClassComparisonWithSignificance = cache(
|
||||
async (params: ClassComparisonParams): Promise<ClassComparisonResult> => {
|
||||
const items = await getClassComparison(params)
|
||||
|
||||
if (items.length < 2) {
|
||||
return { items, significance: null }
|
||||
}
|
||||
|
||||
// 找出均分最高与最低班级
|
||||
let topClass = items[0]
|
||||
let bottomClass = items[0]
|
||||
for (const item of items) {
|
||||
if (item.averageScore > topClass.averageScore) topClass = item
|
||||
if (item.averageScore < bottomClass.averageScore) bottomClass = item
|
||||
}
|
||||
|
||||
if (topClass.classId === bottomClass.classId) {
|
||||
return { items, significance: null }
|
||||
}
|
||||
|
||||
// 重新查询两个班级的原始分数(归一化 0-100),用于统计检验
|
||||
const conditions = [
|
||||
inArray(gradeRecords.classId, [topClass.classId, bottomClass.classId]),
|
||||
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 rawRows = await db
|
||||
.select({
|
||||
classId: gradeRecords.classId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
const topScores: number[] = []
|
||||
const bottomScores: number[] = []
|
||||
for (const r of rawRows) {
|
||||
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
if (r.classId === topClass.classId) topScores.push(normalized)
|
||||
else if (r.classId === bottomClass.classId) bottomScores.push(normalized)
|
||||
}
|
||||
|
||||
const sigResult = computeSignificance(topScores, bottomScores)
|
||||
if (!sigResult) {
|
||||
return { items, significance: null }
|
||||
}
|
||||
|
||||
const significance: ClassComparisonSignificance = {
|
||||
topClassId: topClass.classId,
|
||||
bottomClassId: bottomClass.classId,
|
||||
cohensD: sigResult.cohensD,
|
||||
effectSizeLabel: sigResult.effectSizeLabel,
|
||||
pValue: sigResult.pValue,
|
||||
isSignificant: sigResult.isSignificant,
|
||||
}
|
||||
|
||||
return { items, significance }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 通过 classes.getStudentActiveClassId 查询学生所在班级
|
||||
* 2. 查询该班级所有学生的成绩记录(不含学生 ID,仅聚合计数)
|
||||
* 3. 查询该学生最近的归一化分数
|
||||
* 4. 调用 stats-service.findBucketIndex 计算学生所在桶索引
|
||||
*
|
||||
* 返回 null 的场景:
|
||||
* - 学生未分配到任何班级
|
||||
* - 班级无成绩记录
|
||||
*
|
||||
* @param studentId - 当前学生 ID
|
||||
* @param scope - 当前用户的数据范围(学生为 class_members)
|
||||
*/
|
||||
export const getStudentPositionInClassDistribution = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
scope: DataScope
|
||||
): Promise<GradeDistributionWithPosition | null> => {
|
||||
// 仅学生(class_members scope)调用,且必须传 studentId
|
||||
if (scope.type !== "class_members") return null
|
||||
|
||||
const classId = await getStudentActiveClassId(studentId)
|
||||
if (!classId) return null
|
||||
|
||||
const className = (await getClassNameById(classId)) ?? ""
|
||||
|
||||
// 查询该班级全部学生的成绩记录(聚合计数,不含个人身份)
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
const scopeFilter = buildScopeClassFilter(scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const allRows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
studentId: gradeRecords.studentId,
|
||||
createdAt: gradeRecords.createdAt,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (allRows.length === 0) return null
|
||||
|
||||
const distribution = computeGradeDistribution(
|
||||
allRows.map((r) => ({ score: r.score, fullScore: r.fullScore }))
|
||||
)
|
||||
|
||||
// 获取该学生最近一条成绩记录的归一化分数
|
||||
const studentRows = allRows
|
||||
.filter((r) => r.studentId === studentId)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
if (studentRows.length === 0) {
|
||||
return {
|
||||
distribution,
|
||||
classId,
|
||||
className,
|
||||
studentNormalizedScore: null,
|
||||
studentBucketIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
const latestRow = studentRows[0]
|
||||
const studentNormalizedScore = normalize(
|
||||
toNumber(latestRow.score),
|
||||
toNumber(latestRow.fullScore)
|
||||
)
|
||||
const bucketIndex = findBucketIndex(distribution.buckets, studentNormalizedScore)
|
||||
|
||||
return {
|
||||
distribution,
|
||||
classId,
|
||||
className,
|
||||
studentNormalizedScore,
|
||||
studentBucketIndex: bucketIndex,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 校验 class_taught scope(学生必须属于教师所教班级)
|
||||
* 2. 获取学生姓名
|
||||
* 3. 获取所有学年信息(id → { name, startDate })
|
||||
* 4. 查询学生的全部成绩记录(含 academicYearId)
|
||||
* 5. 调用 stats-service.buildGrowthArchivePoints 计算成长点序列
|
||||
* 6. 计算总览统计(overallAverage / totalRecords / totalSubjects / totalAcademicYears / growthDelta)
|
||||
*
|
||||
* 返回 null 的场景:
|
||||
* - 学生不存在
|
||||
* - 学生无任何成绩记录
|
||||
*
|
||||
* 注:class_members / children scope 的越权校验由 action 层完成(需 ctx.userId)
|
||||
*
|
||||
* @param studentId - 学生 ID
|
||||
* @param scope - 当前用户的数据范围
|
||||
*/
|
||||
export const getStudentGrowthArchive = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
scope: DataScope
|
||||
): Promise<StudentGrowthArchiveResult | null> => {
|
||||
// scope 校验:仅 class_taught 需在 data-access 层校验(其余由 action 层处理)
|
||||
if (scope.type === "class_taught") {
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
if (studentClassId && !scope.classIds.includes(studentClassId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取学生姓名
|
||||
const studentNameMap = await getUserNamesByIds([studentId])
|
||||
const studentName = studentNameMap.get(studentId)?.name ?? null
|
||||
if (!studentName && !studentNameMap.has(studentId)) return null
|
||||
|
||||
// 获取所有学年信息
|
||||
const academicYears = await getAcademicYears()
|
||||
const academicYearMap = new Map<string, { name: string; startDate: Date }>()
|
||||
for (const y of academicYears) {
|
||||
academicYearMap.set(y.id, {
|
||||
name: y.name,
|
||||
startDate: new Date(y.startDate),
|
||||
})
|
||||
}
|
||||
|
||||
// 查询学生的全部成绩记录(含 academicYearId 非空过滤)
|
||||
const conditions = [
|
||||
eq(gradeRecords.studentId, studentId),
|
||||
isNotNull(gradeRecords.academicYearId),
|
||||
ne(gradeRecords.academicYearId, ""),
|
||||
]
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(scope, studentId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
academicYearId: gradeRecords.academicYearId,
|
||||
semester: gradeRecords.semester,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
subjectId: gradeRecords.subjectId,
|
||||
title: gradeRecords.title,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 构建原始数据行(合并学年信息)
|
||||
const archiveRows = rows
|
||||
.map((r) => {
|
||||
const yearId = r.academicYearId
|
||||
if (!yearId) return null
|
||||
const yearInfo = academicYearMap.get(yearId)
|
||||
if (!yearInfo) return null
|
||||
return {
|
||||
academicYearId: yearId,
|
||||
academicYearName: yearInfo.name,
|
||||
academicYearStart: yearInfo.startDate,
|
||||
semester: r.semester as "1" | "2",
|
||||
score: r.score,
|
||||
fullScore: r.fullScore,
|
||||
subjectId: r.subjectId,
|
||||
title: r.title,
|
||||
}
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null)
|
||||
|
||||
if (archiveRows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 调用 stats-service 纯函数构建成长点序列
|
||||
const points = buildGrowthArchivePoints(archiveRows)
|
||||
if (points.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 计算总览统计
|
||||
const allNormalizedScores = rows
|
||||
.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
const overallAverage = computeAverageScore(allNormalizedScores)
|
||||
const totalSubjects = new Set(rows.map((r) => r.subjectId)).size
|
||||
const totalAcademicYears = new Set(
|
||||
archiveRows.map((r) => r.academicYearId)
|
||||
).size
|
||||
|
||||
// 成长趋势:最新点平均分 - 第一个点平均分
|
||||
const firstPoint = points[0]
|
||||
const lastPoint = points[points.length - 1]
|
||||
const growthDelta = Math.round(
|
||||
(lastPoint.averageScore - firstPoint.averageScore) * 100
|
||||
) / 100
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: studentName ?? "Unknown",
|
||||
points,
|
||||
overallAverage,
|
||||
totalRecords: rows.length,
|
||||
totalSubjects,
|
||||
totalAcademicYears,
|
||||
growthDelta,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface SubjectComparisonParams {
|
||||
classId: string
|
||||
examId?: string
|
||||
@@ -349,18 +656,21 @@ export const getSchoolWideGradeSummary = cache(
|
||||
)
|
||||
|
||||
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,
|
||||
}))
|
||||
.filter((r): r is typeof r & { stats: NonNullable<typeof r.stats> } => r.stats !== null)
|
||||
.map((r) => {
|
||||
const stats = r.stats
|
||||
return {
|
||||
gradeId: r.grade.id,
|
||||
gradeName: r.grade.name,
|
||||
schoolName: r.grade.school.name,
|
||||
classCount: r.classCount,
|
||||
studentCount: r.studentCount,
|
||||
recordCount: r.recordCount,
|
||||
averageScore: stats.average,
|
||||
passRate: stats.passRate,
|
||||
excellentRate: stats.excellentRate,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.schoolName.localeCompare(b.schoolName) || a.gradeName.localeCompare(b.gradeName))
|
||||
|
||||
// 计算全校汇总(按记录数加权平均)
|
||||
|
||||
260
src/modules/grades/data-access-appeals.ts
Normal file
260
src/modules/grades/data-access-appeals.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeAppeals, gradeRecords } from "@/shared/db/schema"
|
||||
|
||||
/**
|
||||
* P3-7 成绩申诉数据访问层。
|
||||
*
|
||||
* 申诉状态机:pending → approved/rejected/withdrawn
|
||||
* 一条 grade_record 同时只允许一条 pending 申诉(应用层校验)。
|
||||
* 成绩修改全程留痕:originalScore 快照 + adjustedScore + reviewComment。
|
||||
*/
|
||||
|
||||
export interface GradeAppeal {
|
||||
id: string
|
||||
gradeRecordId: string
|
||||
studentId: string
|
||||
originalScore: string
|
||||
expectedScore: string | null
|
||||
reason: string
|
||||
status: "pending" | "approved" | "rejected" | "withdrawn"
|
||||
reviewerId: string | null
|
||||
reviewComment: string | null
|
||||
adjustedScore: string | null
|
||||
reviewedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GradeAppealWithRecord extends GradeAppeal {
|
||||
gradeRecordTitle: string
|
||||
gradeRecordSubjectId: string
|
||||
gradeRecordClassId: string
|
||||
studentName: string | null
|
||||
reviewerName: string | null
|
||||
}
|
||||
|
||||
/** 类型转换:DB 行 → 业务对象 */
|
||||
function serializeAppeal(row: typeof gradeAppeals.$inferSelect): GradeAppeal {
|
||||
return {
|
||||
id: row.id,
|
||||
gradeRecordId: row.gradeRecordId,
|
||||
studentId: row.studentId,
|
||||
originalScore: row.originalScore,
|
||||
expectedScore: row.expectedScore,
|
||||
reason: row.reason,
|
||||
status: row.status,
|
||||
reviewerId: row.reviewerId,
|
||||
reviewComment: row.reviewComment,
|
||||
adjustedScore: row.adjustedScore,
|
||||
reviewedAt: row.reviewedAt ? row.reviewedAt.toISOString() : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查某成绩记录是否已有 pending 申诉。
|
||||
* 用于提交前校验,避免重复申诉。
|
||||
*/
|
||||
export async function hasPendingAppeal(gradeRecordId: string): Promise<boolean> {
|
||||
const existing = await db.query.gradeAppeals.findFirst({
|
||||
where: and(
|
||||
eq(gradeAppeals.gradeRecordId, gradeRecordId),
|
||||
eq(gradeAppeals.status, "pending")
|
||||
),
|
||||
})
|
||||
return Boolean(existing)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成绩申诉。
|
||||
* 调用方需先校验:1) 成绩存在且属于当前学生;2) 无 pending 申诉。
|
||||
*/
|
||||
export async function createGradeAppeal(params: {
|
||||
gradeRecordId: string
|
||||
studentId: string
|
||||
originalScore: string
|
||||
reason: string
|
||||
expectedScore?: number
|
||||
}): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(gradeAppeals).values({
|
||||
id,
|
||||
gradeRecordId: params.gradeRecordId,
|
||||
studentId: params.studentId,
|
||||
originalScore: params.originalScore,
|
||||
expectedScore: params.expectedScore !== undefined ? String(params.expectedScore) : null,
|
||||
reason: params.reason,
|
||||
status: "pending",
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生自己的申诉列表(按创建时间倒序)。
|
||||
*/
|
||||
export const getStudentAppeals = cache(
|
||||
async (studentId: string): Promise<GradeAppeal[]> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(gradeAppeals)
|
||||
.where(eq(gradeAppeals.studentId, studentId))
|
||||
.orderBy(desc(gradeAppeals.createdAt))
|
||||
|
||||
return rows.map(serializeAppeal)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取教师待复核的申诉列表(按创建时间正序,先到先审)。
|
||||
* 仅返回 pending 状态的申诉。
|
||||
*/
|
||||
export const getPendingAppealsForReview = cache(
|
||||
async (classIds: string[]): Promise<GradeAppealWithRecord[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
appeal: gradeAppeals,
|
||||
gradeRecord: gradeRecords,
|
||||
})
|
||||
.from(gradeAppeals)
|
||||
.innerJoin(gradeRecords, eq(gradeAppeals.gradeRecordId, gradeRecords.id))
|
||||
.where(
|
||||
and(
|
||||
eq(gradeAppeals.status, "pending"),
|
||||
// 通过 gradeRecord.classId 过滤(教师只能看自己班级的申诉)
|
||||
)
|
||||
)
|
||||
.orderBy(gradeAppeals.createdAt)
|
||||
|
||||
// 在 JS 层过滤班级范围(避免复杂 SQL join)
|
||||
const filtered = rows.filter((r) => classIds.includes(r.gradeRecord.classId))
|
||||
|
||||
return filtered.map((r) => ({
|
||||
...serializeAppeal(r.appeal),
|
||||
gradeRecordTitle: r.gradeRecord.title,
|
||||
gradeRecordSubjectId: r.gradeRecord.subjectId,
|
||||
gradeRecordClassId: r.gradeRecord.classId,
|
||||
studentName: null,
|
||||
reviewerName: null,
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取单个申诉详情(含成绩信息)。
|
||||
* 调用方需校验:学生只能看自己的申诉,教师只能看自己班级的申诉。
|
||||
*/
|
||||
export const getAppealById = cache(
|
||||
async (appealId: string): Promise<GradeAppealWithRecord | null> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
appeal: gradeAppeals,
|
||||
gradeRecord: gradeRecords,
|
||||
})
|
||||
.from(gradeAppeals)
|
||||
.innerJoin(gradeRecords, eq(gradeAppeals.gradeRecordId, gradeRecords.id))
|
||||
.where(eq(gradeAppeals.id, appealId))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) return null
|
||||
const row = rows[0]
|
||||
return {
|
||||
...serializeAppeal(row.appeal),
|
||||
gradeRecordTitle: row.gradeRecord.title,
|
||||
gradeRecordSubjectId: row.gradeRecord.subjectId,
|
||||
gradeRecordClassId: row.gradeRecord.classId,
|
||||
studentName: null,
|
||||
reviewerName: null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 教师复核申诉(状态机转换:pending → approved/rejected)。
|
||||
*
|
||||
* 若 decision=approved 且 adjustedScore 有值:
|
||||
* 1. 更新申诉记录(reviewerId, reviewComment, adjustedScore, reviewedAt, status)
|
||||
* 2. 同步更新 grade_records.score(留痕:originalScore 已在申诉记录中快照)
|
||||
*
|
||||
* 若 decision=rejected:
|
||||
* 1. 仅更新申诉记录,不修改成绩
|
||||
*/
|
||||
export async function reviewGradeAppeal(params: {
|
||||
appealId: string
|
||||
reviewerId: string
|
||||
decision: "approved" | "rejected"
|
||||
reviewComment: string
|
||||
adjustedScore?: number
|
||||
}): Promise<void> {
|
||||
const { appealId, reviewerId, decision, reviewComment, adjustedScore } = params
|
||||
|
||||
const appeal = await db.query.gradeAppeals.findFirst({
|
||||
where: eq(gradeAppeals.id, appealId),
|
||||
})
|
||||
|
||||
if (!appeal) {
|
||||
throw new Error("Appeal not found")
|
||||
}
|
||||
if (appeal.status !== "pending") {
|
||||
throw new Error("Appeal is not pending")
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// 更新申诉记录
|
||||
await db
|
||||
.update(gradeAppeals)
|
||||
.set({
|
||||
status: decision,
|
||||
reviewerId,
|
||||
reviewComment,
|
||||
adjustedScore: adjustedScore !== undefined ? String(adjustedScore) : null,
|
||||
reviewedAt: now,
|
||||
})
|
||||
.where(eq(gradeAppeals.id, appealId))
|
||||
|
||||
// 若批准且分数有变,同步更新成绩记录
|
||||
if (decision === "approved" && adjustedScore !== undefined) {
|
||||
await db
|
||||
.update(gradeRecords)
|
||||
.set({ score: String(adjustedScore) })
|
||||
.where(eq(gradeRecords.id, appeal.gradeRecordId))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生撤回申诉(状态机转换:pending → withdrawn)。
|
||||
*/
|
||||
export async function withdrawGradeAppeal(params: {
|
||||
appealId: string
|
||||
studentId: string
|
||||
}): Promise<void> {
|
||||
const { appealId, studentId } = params
|
||||
|
||||
const appeal = await db.query.gradeAppeals.findFirst({
|
||||
where: and(
|
||||
eq(gradeAppeals.id, appealId),
|
||||
eq(gradeAppeals.studentId, studentId)
|
||||
),
|
||||
})
|
||||
|
||||
if (!appeal) {
|
||||
throw new Error("Appeal not found or not owned by student")
|
||||
}
|
||||
if (appeal.status !== "pending") {
|
||||
throw new Error("Appeal is not pending")
|
||||
}
|
||||
|
||||
await db
|
||||
.update(gradeAppeals)
|
||||
.set({ status: "withdrawn" })
|
||||
.where(eq(gradeAppeals.id, appealId))
|
||||
}
|
||||
405
src/modules/grades/data-access-drafts.ts
Normal file
405
src/modules/grades/data-access-drafts.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq, isNotNull, isNull, or, lt } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeDrafts } from "@/shared/db/schema"
|
||||
|
||||
import { isGradeDraftData } from "./lib/type-guards"
|
||||
|
||||
/**
|
||||
* 锁机制常量。
|
||||
*
|
||||
* P3-6 协同录入锁:避免多位教师同时编辑同一班级+科目+类型的成绩,
|
||||
* 通过乐观 TTL(5 分钟)+ 令牌续期的方式实现悲观锁语义。
|
||||
*/
|
||||
const LOCK_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 成绩录入草稿数据访问层。
|
||||
*
|
||||
* P1-2 重构:从 data-access.ts 拆分而来,使 data-access.ts 回到 800 行以内。
|
||||
* 草稿机制允许教师跨设备恢复未提交的成绩录入。
|
||||
*/
|
||||
|
||||
/** 草稿数据结构(持久化到 DB JSON 列) */
|
||||
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
|
||||
|
||||
// 从 DB JSON 列解析,使用类型守卫确保结构安全
|
||||
if (!isGradeDraftData(draft.content)) return null
|
||||
const content = draft.content
|
||||
// 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// P3-6 协同录入锁机制
|
||||
// ============================================================================
|
||||
|
||||
/** 锁状态信息(含持有者信息,用于前端展示提示) */
|
||||
export interface DraftLockStatus {
|
||||
isLocked: boolean
|
||||
lockedBy: string | null
|
||||
lockedByName: string | null
|
||||
lockedAt: string | null
|
||||
isExpired: boolean
|
||||
isMine: boolean
|
||||
}
|
||||
|
||||
/** 获取锁的结果(包含令牌,前端续期/释放时需回传) */
|
||||
export interface AcquireLockResult {
|
||||
acquired: boolean
|
||||
lockToken: string | null
|
||||
status: DraftLockStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某班级+科目+类型的锁状态。
|
||||
*
|
||||
* 锁以草稿记录为载体:草稿存在 lockedBy 字段即视为被锁。
|
||||
* 超过 TTL 的锁视为过期(自动失效)。
|
||||
*/
|
||||
export async function getDraftLockStatus(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
currentUserId: string
|
||||
}): Promise<DraftLockStatus> {
|
||||
const { classId, subjectId, type, currentUserId } = params
|
||||
|
||||
const draft = await db.query.gradeDrafts.findFirst({
|
||||
where: and(
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type)
|
||||
),
|
||||
})
|
||||
|
||||
if (!draft || !draft.lockedBy) {
|
||||
return {
|
||||
isLocked: false,
|
||||
lockedBy: null,
|
||||
lockedByName: null,
|
||||
lockedAt: null,
|
||||
isExpired: false,
|
||||
isMine: false,
|
||||
}
|
||||
}
|
||||
|
||||
const lockedAtMs = draft.lockedAt ? new Date(draft.lockedAt).getTime() : 0
|
||||
const isExpired = Date.now() - lockedAtMs > LOCK_TTL_MS
|
||||
|
||||
return {
|
||||
isLocked: !isExpired,
|
||||
lockedBy: draft.lockedBy,
|
||||
lockedByName: null, // 不做跨模块 join,由 Action 层补充姓名
|
||||
lockedAt: draft.lockedAt ? draft.lockedAt.toISOString() : null,
|
||||
isExpired,
|
||||
isMine: draft.lockedBy === currentUserId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿锁(悲观语义)。
|
||||
*
|
||||
* 规则:
|
||||
* 1. 若锁不存在或已过期 → 创建/覆盖锁
|
||||
* 2. 若锁被自己持有且未过期 → 视为续期成功,刷新 token + 时间
|
||||
* 3. 若锁被他人持有且未过期 → 拒绝,返回当前状态
|
||||
* 4. 若锁被他人持有但已过期 → 抢占
|
||||
*/
|
||||
export async function acquireDraftLock(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
userId: string
|
||||
}): Promise<AcquireLockResult> {
|
||||
const { classId, subjectId, type, userId } = params
|
||||
|
||||
const status = await getDraftLockStatus({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
currentUserId: userId,
|
||||
})
|
||||
|
||||
// 情况 3:他人持锁且未过期
|
||||
if (status.isLocked && !status.isMine) {
|
||||
return { acquired: false, lockToken: null, status }
|
||||
}
|
||||
|
||||
// 情况 1/2/4:创建/续期/抢占
|
||||
const lockToken = createId()
|
||||
const now = new Date()
|
||||
|
||||
const existing = await db.query.gradeDrafts.findFirst({
|
||||
where: and(
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type)
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(gradeDrafts)
|
||||
.set({
|
||||
lockedBy: userId,
|
||||
lockedAt: now,
|
||||
lockToken,
|
||||
})
|
||||
.where(eq(gradeDrafts.id, existing.id))
|
||||
} else {
|
||||
// 锁可以独立于草稿内容存在(教师刚进入页面尚未输入成绩)
|
||||
await db.insert(gradeDrafts).values({
|
||||
id: createId(),
|
||||
userId,
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
content: { scores: {}, timestamp: now.getTime() },
|
||||
lockedBy: userId,
|
||||
lockedAt: now,
|
||||
lockToken,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
acquired: true,
|
||||
lockToken,
|
||||
status: {
|
||||
isLocked: true,
|
||||
lockedBy: userId,
|
||||
lockedByName: null,
|
||||
lockedAt: now.toISOString(),
|
||||
isExpired: false,
|
||||
isMine: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期锁(心跳机制)。
|
||||
*
|
||||
* 前端每隔 2-3 分钟续期一次,避免教师长时间编辑时锁过期被抢占。
|
||||
* 校验 lockToken 防止他人伪造 userId 续期。
|
||||
*/
|
||||
export async function renewDraftLock(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
userId: string
|
||||
lockToken: string
|
||||
}): Promise<AcquireLockResult> {
|
||||
const { classId, subjectId, type, userId, lockToken } = params
|
||||
|
||||
const draft = await db.query.gradeDrafts.findFirst({
|
||||
where: and(
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type)
|
||||
),
|
||||
})
|
||||
|
||||
if (!draft || !draft.lockedBy) {
|
||||
return {
|
||||
acquired: false,
|
||||
lockToken: null,
|
||||
status: await getDraftLockStatus({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
currentUserId: userId,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// 令牌或持有人不匹配 → 拒绝
|
||||
if (draft.lockedBy !== userId || draft.lockToken !== lockToken) {
|
||||
return {
|
||||
acquired: false,
|
||||
lockToken: null,
|
||||
status: await getDraftLockStatus({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
currentUserId: userId,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const newToken = createId()
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(gradeDrafts)
|
||||
.set({ lockedAt: now, lockToken: newToken })
|
||||
.where(eq(gradeDrafts.id, draft.id))
|
||||
|
||||
return {
|
||||
acquired: true,
|
||||
lockToken: newToken,
|
||||
status: {
|
||||
isLocked: true,
|
||||
lockedBy: userId,
|
||||
lockedByName: null,
|
||||
lockedAt: now.toISOString(),
|
||||
isExpired: false,
|
||||
isMine: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动释放锁(提交成功或用户主动退出时调用)。
|
||||
*
|
||||
* 仅当 token + userId 匹配时才允许释放,避免他人恶意解锁。
|
||||
*/
|
||||
export async function releaseDraftLock(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
userId: string
|
||||
lockToken: string
|
||||
}): Promise<boolean> {
|
||||
const { classId, subjectId, type, userId, lockToken } = params
|
||||
|
||||
const result = await db
|
||||
.update(gradeDrafts)
|
||||
.set({ lockedBy: null, lockedAt: null, lockToken: null })
|
||||
.where(
|
||||
and(
|
||||
eq(gradeDrafts.classId, classId),
|
||||
eq(gradeDrafts.subjectId, subjectId),
|
||||
eq(gradeDrafts.type, type),
|
||||
eq(gradeDrafts.lockedBy, userId),
|
||||
eq(gradeDrafts.lockToken, lockToken)
|
||||
)
|
||||
)
|
||||
|
||||
// drizzle MySQL 返回 [ResultSetHeader, FieldPacket[]]
|
||||
const [header] = result
|
||||
return (header?.affectedRows ?? 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有过期锁(Cron 任务调用)。
|
||||
*
|
||||
* 由于 acquireDraftLock 已实现"过期可抢占"语义,
|
||||
* 此函数主要用于:1) 减小表体积;2) 让前端轮询看到干净的"无锁"状态。
|
||||
*/
|
||||
export async function cleanupExpiredDraftLocks(): Promise<number> {
|
||||
const expiredThreshold = new Date(Date.now() - LOCK_TTL_MS)
|
||||
|
||||
const result = await db
|
||||
.update(gradeDrafts)
|
||||
.set({ lockedBy: null, lockedAt: null, lockToken: null })
|
||||
.where(
|
||||
and(
|
||||
isNotNull(gradeDrafts.lockedBy),
|
||||
or(
|
||||
isNull(gradeDrafts.lockedAt),
|
||||
lt(gradeDrafts.lockedAt, expiredThreshold)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const [header] = result
|
||||
return header?.affectedRows ?? 0
|
||||
}
|
||||
189
src/modules/grades/data-access-exam-entry.ts
Normal file
189
src/modules/grades/data-access-exam-entry.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import "server-only"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
examQuestions,
|
||||
examSubmissions,
|
||||
gradeRecordAnswers,
|
||||
gradeRecords,
|
||||
submissionAnswers,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import type { BatchGradeEntryByExamInput } from "./schema"
|
||||
|
||||
/**
|
||||
* 按试卷批量录入成绩的数据访问层。
|
||||
*
|
||||
* P1-2 重构:从 data-access.ts 拆分而来。
|
||||
* 该文件专注于“按试卷录入”这一独立用例的数据写入逻辑。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 按试卷批量录入成绩(每题得分)。
|
||||
*
|
||||
* 写入流程(单事务):
|
||||
* 1. 查询 exam_questions 获取每题满分,并校验所有 questionId 属于该试卷
|
||||
* 2. 查询已有 exam_submissions(避免主键冲突)
|
||||
* 3. 为每个学生:
|
||||
* - 创建 grade_records(score = 各题得分之和)
|
||||
* - 创建 grade_record_answers(每题得分)
|
||||
* - 创建/更新 exam_submissions(status='graded',投影供下游模块使用)
|
||||
* - 创建/替换 submission_answers(answerContent=null,投影每题得分)
|
||||
*
|
||||
* 投影设计:教师录入的成绩通过 exam_submissions + submission_answers 体现,
|
||||
* 这样错题集、成绩分析等下游模块无需改造即可读取。
|
||||
*
|
||||
* 返回每条记录的 { gradeRecordId, studentId, submissionId },
|
||||
* 供调用方在事务后触发错题采集等后置钩子。
|
||||
*/
|
||||
export interface BatchGradeRecordResult {
|
||||
gradeRecordId: string
|
||||
studentId: string
|
||||
submissionId: string
|
||||
}
|
||||
|
||||
export async function batchCreateGradeRecordsByExam(
|
||||
data: BatchGradeEntryByExamInput,
|
||||
recordedBy: string
|
||||
): Promise<BatchGradeRecordResult[]> {
|
||||
if (data.records.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
const results: BatchGradeRecordResult[] = []
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// 1. 查询试卷题目(获取每题满分 + 验证 questionId 归属)
|
||||
const examQuestionRows = await tx
|
||||
.select({
|
||||
questionId: examQuestions.questionId,
|
||||
score: examQuestions.score,
|
||||
})
|
||||
.from(examQuestions)
|
||||
.where(eq(examQuestions.examId, data.examId))
|
||||
|
||||
if (examQuestionRows.length === 0) {
|
||||
throw new Error("Exam has no questions, cannot enter grades")
|
||||
}
|
||||
|
||||
const questionFullScoreMap = new Map(
|
||||
examQuestionRows.map((q) => [q.questionId, q.score ?? 0])
|
||||
)
|
||||
|
||||
// 校验所有 questionId 都属于该试卷
|
||||
for (const record of data.records) {
|
||||
for (const answer of record.answers) {
|
||||
if (!questionFullScoreMap.has(answer.questionId)) {
|
||||
throw new Error(
|
||||
`Question ${answer.questionId} does not belong to exam ${data.examId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询已有 exam_submissions(避免冲突)
|
||||
const studentIds = data.records.map((r) => r.studentId)
|
||||
const existingSubmissions = await tx
|
||||
.select({
|
||||
id: examSubmissions.id,
|
||||
studentId: examSubmissions.studentId,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(examSubmissions.examId, data.examId),
|
||||
inArray(examSubmissions.studentId, studentIds)
|
||||
)
|
||||
)
|
||||
const existingSubmissionMap = new Map(
|
||||
existingSubmissions.map((s) => [s.studentId, s.id])
|
||||
)
|
||||
|
||||
// 3. 为每个学生创建记录
|
||||
for (const record of data.records) {
|
||||
const gradeRecordId = createId()
|
||||
|
||||
const totalScore = record.answers.reduce(
|
||||
(sum, a) => sum + a.score,
|
||||
0
|
||||
)
|
||||
|
||||
// 3a. grade_records
|
||||
await tx.insert(gradeRecords).values({
|
||||
id: gradeRecordId,
|
||||
studentId: record.studentId,
|
||||
classId: data.classId,
|
||||
subjectId: data.subjectId,
|
||||
examId: data.examId,
|
||||
academicYearId: null,
|
||||
title: data.title,
|
||||
score: String(totalScore),
|
||||
fullScore: String(data.fullScore),
|
||||
type: data.type ?? "exam",
|
||||
semester: data.semester ?? "1",
|
||||
recordedBy,
|
||||
remark: record.remark ?? null,
|
||||
})
|
||||
|
||||
// 3b. grade_record_answers
|
||||
const answerRows = record.answers.map((a) => ({
|
||||
id: createId(),
|
||||
gradeRecordId,
|
||||
questionId: a.questionId,
|
||||
score: String(a.score),
|
||||
fullScore: String(questionFullScoreMap.get(a.questionId) ?? 0),
|
||||
feedback: null,
|
||||
}))
|
||||
await tx.insert(gradeRecordAnswers).values(answerRows)
|
||||
|
||||
// 3c. 投影到 exam_submissions
|
||||
const existingSubmissionId = existingSubmissionMap.get(record.studentId)
|
||||
let submissionId: string
|
||||
|
||||
if (existingSubmissionId) {
|
||||
await tx
|
||||
.update(examSubmissions)
|
||||
.set({
|
||||
score: totalScore,
|
||||
status: "graded",
|
||||
submittedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(examSubmissions.id, existingSubmissionId))
|
||||
submissionId = existingSubmissionId
|
||||
|
||||
// 删除旧 submission_answers
|
||||
await tx
|
||||
.delete(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
} else {
|
||||
submissionId = createId()
|
||||
await tx.insert(examSubmissions).values({
|
||||
id: submissionId,
|
||||
examId: data.examId,
|
||||
studentId: record.studentId,
|
||||
score: totalScore,
|
||||
status: "graded",
|
||||
submittedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// 3d. submission_answers 投影(answerContent=null,只有得分)
|
||||
const submissionAnswerRows = record.answers.map((a) => ({
|
||||
id: createId(),
|
||||
submissionId,
|
||||
questionId: a.questionId,
|
||||
answerContent: null,
|
||||
score: a.score,
|
||||
feedback: null,
|
||||
}))
|
||||
await tx.insert(submissionAnswers).values(submissionAnswerRows)
|
||||
|
||||
results.push({ gradeRecordId, studentId: record.studentId, submissionId })
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeDrafts, gradeRecords, gradeRecordAnswers, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassExists,
|
||||
@@ -32,11 +32,19 @@ import type {
|
||||
} from "./types"
|
||||
import type {
|
||||
BatchCreateGradeRecordInput,
|
||||
BatchGradeEntryByExamInput,
|
||||
CreateGradeRecordInput,
|
||||
UpdateGradeRecordInput,
|
||||
} from "./schema"
|
||||
|
||||
/**
|
||||
* grades 模块核心数据访问层。
|
||||
*
|
||||
* P1-2 重构:
|
||||
* - 草稿 CRUD(saveGradeDraft/getGradeDraft/deleteGradeDraft)已迁移至 data-access-drafts.ts
|
||||
* - 按试卷录入(batchCreateGradeRecordsByExam)已迁移至 data-access-exam-entry.ts
|
||||
* 本文件仅保留成绩记录的核心 CRUD 与统计查询。
|
||||
*/
|
||||
|
||||
/** 分页查询结果 */
|
||||
export interface PaginatedGradeRecords {
|
||||
records: GradeRecordListItem[]
|
||||
@@ -197,163 +205,6 @@ export async function batchCreateGradeRecords(
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按试卷批量录入成绩(每题得分)。
|
||||
*
|
||||
* 写入流程(单事务):
|
||||
* 1. 查询 exam_questions 获取每题满分,并校验所有 questionId 属于该试卷
|
||||
* 2. 查询已有 exam_submissions(避免主键冲突)
|
||||
* 3. 为每个学生:
|
||||
* - 创建 grade_records(score = 各题得分之和)
|
||||
* - 创建 grade_record_answers(每题得分)
|
||||
* - 创建/更新 exam_submissions(status='graded',投影供下游模块使用)
|
||||
* - 创建/替换 submission_answers(answerContent=null,投影每题得分)
|
||||
*
|
||||
* 投影设计:教师录入的成绩通过 exam_submissions + submission_answers 体现,
|
||||
* 这样错题集、成绩分析等下游模块无需改造即可读取。
|
||||
*/
|
||||
export async function batchCreateGradeRecordsByExam(
|
||||
data: BatchGradeEntryByExamInput,
|
||||
recordedBy: string
|
||||
): Promise<string[]> {
|
||||
if (data.records.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
const gradeRecordIds: string[] = []
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// 1. 查询试卷题目(获取每题满分 + 验证 questionId 归属)
|
||||
const examQuestionRows = await tx
|
||||
.select({
|
||||
questionId: examQuestions.questionId,
|
||||
score: examQuestions.score,
|
||||
})
|
||||
.from(examQuestions)
|
||||
.where(eq(examQuestions.examId, data.examId))
|
||||
|
||||
if (examQuestionRows.length === 0) {
|
||||
throw new Error("试卷没有题目,无法录入成绩")
|
||||
}
|
||||
|
||||
const questionFullScoreMap = new Map(
|
||||
examQuestionRows.map((q) => [q.questionId, q.score ?? 0])
|
||||
)
|
||||
|
||||
// 校验所有 questionId 都属于该试卷
|
||||
for (const record of data.records) {
|
||||
for (const answer of record.answers) {
|
||||
if (!questionFullScoreMap.has(answer.questionId)) {
|
||||
throw new Error(
|
||||
`题目 ${answer.questionId} 不属于试卷 ${data.examId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询已有 exam_submissions(避免冲突)
|
||||
const studentIds = data.records.map((r) => r.studentId)
|
||||
const existingSubmissions = await tx
|
||||
.select({
|
||||
id: examSubmissions.id,
|
||||
studentId: examSubmissions.studentId,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(examSubmissions.examId, data.examId),
|
||||
inArray(examSubmissions.studentId, studentIds)
|
||||
)
|
||||
)
|
||||
const existingSubmissionMap = new Map(
|
||||
existingSubmissions.map((s) => [s.studentId, s.id])
|
||||
)
|
||||
|
||||
// 3. 为每个学生创建记录
|
||||
for (const record of data.records) {
|
||||
const gradeRecordId = createId()
|
||||
gradeRecordIds.push(gradeRecordId)
|
||||
|
||||
const totalScore = record.answers.reduce(
|
||||
(sum, a) => sum + a.score,
|
||||
0
|
||||
)
|
||||
|
||||
// 3a. grade_records
|
||||
await tx.insert(gradeRecords).values({
|
||||
id: gradeRecordId,
|
||||
studentId: record.studentId,
|
||||
classId: data.classId,
|
||||
subjectId: data.subjectId,
|
||||
examId: data.examId,
|
||||
academicYearId: null,
|
||||
title: data.title,
|
||||
score: String(totalScore),
|
||||
fullScore: String(data.fullScore),
|
||||
type: data.type ?? "exam",
|
||||
semester: data.semester ?? "1",
|
||||
recordedBy,
|
||||
remark: record.remark ?? null,
|
||||
})
|
||||
|
||||
// 3b. grade_record_answers
|
||||
const answerRows = record.answers.map((a) => ({
|
||||
id: createId(),
|
||||
gradeRecordId,
|
||||
questionId: a.questionId,
|
||||
score: String(a.score),
|
||||
fullScore: String(questionFullScoreMap.get(a.questionId) ?? 0),
|
||||
feedback: null,
|
||||
}))
|
||||
await tx.insert(gradeRecordAnswers).values(answerRows)
|
||||
|
||||
// 3c. 投影到 exam_submissions
|
||||
const existingSubmissionId = existingSubmissionMap.get(record.studentId)
|
||||
let submissionId: string
|
||||
|
||||
if (existingSubmissionId) {
|
||||
await tx
|
||||
.update(examSubmissions)
|
||||
.set({
|
||||
score: totalScore,
|
||||
status: "graded",
|
||||
submittedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(examSubmissions.id, existingSubmissionId))
|
||||
submissionId = existingSubmissionId
|
||||
|
||||
// 删除旧 submission_answers
|
||||
await tx
|
||||
.delete(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
} else {
|
||||
submissionId = createId()
|
||||
await tx.insert(examSubmissions).values({
|
||||
id: submissionId,
|
||||
examId: data.examId,
|
||||
studentId: record.studentId,
|
||||
score: totalScore,
|
||||
status: "graded",
|
||||
submittedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// 3d. submission_answers 投影(answerContent=null,只有得分)
|
||||
const submissionAnswerRows = record.answers.map((a) => ({
|
||||
id: createId(),
|
||||
submissionId,
|
||||
questionId: a.questionId,
|
||||
answerContent: null,
|
||||
score: a.score,
|
||||
feedback: null,
|
||||
}))
|
||||
await tx.insert(submissionAnswers).values(submissionAnswerRows)
|
||||
}
|
||||
})
|
||||
|
||||
return gradeRecordIds
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-3: 批量撤销成绩录入。
|
||||
* 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。
|
||||
@@ -393,7 +244,8 @@ export async function updateGradeRecord(
|
||||
throw new NotFoundError("成绩记录")
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() }
|
||||
// P2-3 修复:使用 Partial<typeof gradeRecords.$inferInsert> 替代 Record<string, unknown>
|
||||
const update: Partial<typeof gradeRecords.$inferInsert> = { updatedAt: new Date() }
|
||||
if (data.title !== undefined) update.title = data.title
|
||||
if (data.score !== undefined) update.score = String(data.score)
|
||||
if (data.fullScore !== undefined) update.fullScore = String(data.fullScore)
|
||||
@@ -467,6 +319,60 @@ export const getClassGradeStats = cache(
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* P2-4: 计算单个学生在班级中的排名。
|
||||
*
|
||||
* 解耦自 getClassRanking:避免获取整个班级排名(含姓名查询)只为查单个学生排名。
|
||||
* 使用 SQL 直接计算:rank = (平均分严格高于该学生的不同平均分数) + 1。
|
||||
* 处理并列:相同平均分的学生共享名次(competition ranking)。
|
||||
*/
|
||||
export async function getStudentRankInClass(
|
||||
studentId: string,
|
||||
classId: string,
|
||||
scope?: DataScope
|
||||
): Promise<number> {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
|
||||
if (scope) {
|
||||
const scopeFilter = buildScopeClassFilter(scope, studentId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
// 查询该学生在此班级的平均分
|
||||
const [studentAvgRow] = await db
|
||||
.select({
|
||||
avgScore: sql<number>`AVG(${gradeRecords.score})`,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions, eq(gradeRecords.studentId, studentId)))
|
||||
|
||||
if (!studentAvgRow || studentAvgRow.avgScore === null) return 0
|
||||
|
||||
const studentAvg = toNumber(studentAvgRow.avgScore)
|
||||
|
||||
// 获取所有学生的平均分,计算平均分严格高于该学生的不同平均分数(处理并列)
|
||||
const allStudentAvgs = await db
|
||||
.select({
|
||||
studentId: gradeRecords.studentId,
|
||||
avgScore: sql<number>`AVG(${gradeRecords.score})`,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.groupBy(gradeRecords.studentId)
|
||||
|
||||
const distinctHigherAvgs = new Set<number>()
|
||||
for (const row of allStudentAvgs) {
|
||||
if (row.studentId !== studentId) {
|
||||
const otherAvg = toNumber(row.avgScore)
|
||||
if (otherAvg > studentAvg) {
|
||||
distinctHigherAvgs.add(Math.round(otherAvg * 100) / 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distinctHigherAvgs.size + 1
|
||||
}
|
||||
|
||||
export const getStudentGradeSummary = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
@@ -546,13 +452,12 @@ export const getStudentGradeSummary = cache(
|
||||
|
||||
const avg = computeAverageScore(listItems.map((i) => i.score))
|
||||
|
||||
// v3-P1-3 修复:计算实际班级排名(不再硬编码为 0)
|
||||
// P2-4 修复:使用独立的 getStudentRankInClass 函数替代 getClassRanking,
|
||||
// 避免获取整个班级排名只需查单个学生排名的 N+1 查询
|
||||
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
|
||||
rank = await getStudentRankInClass(studentId, studentClassId, scope)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -697,107 +602,3 @@ 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,15 +219,15 @@ export async function exportStudentGradeRecordsToExcel(params: {
|
||||
}): Promise<Buffer> {
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
// 查询该学生的所有成绩记录(scope 为 children,会自动按 studentId 过滤)
|
||||
// DB 层按 studentId 过滤,避免内存过滤性能问题
|
||||
const records = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
currentUserId: params.currentUserId,
|
||||
studentId: params.studentId,
|
||||
subjectId: params.subjectId,
|
||||
})
|
||||
|
||||
// 仅保留指定学生的记录
|
||||
const studentRecords = records.records.filter((r) => r.studentId === params.studentId)
|
||||
const studentRecords = records.records
|
||||
|
||||
const detailRows = studentRecords.map((r) => ({
|
||||
studentName: r.studentName,
|
||||
|
||||
100
src/modules/grades/hooks/use-batch-grade-entry-undo.ts
Normal file
100
src/modules/grades/hooks/use-batch-grade-entry-undo.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { undoBatchCreateGradeRecordsAction } from "../actions"
|
||||
|
||||
/**
|
||||
* 撤销批量成绩录入的 Hook。
|
||||
*
|
||||
* P1-6/P1-7 重构:从 batch-grade-entry.tsx 拆分而来。
|
||||
* - 用 `isUndoData` 类型守卫替代 `as` 断言(P1-7)
|
||||
* - 撤销令牌持久化在 sessionStorage,5 分钟内有效
|
||||
*/
|
||||
|
||||
/** 撤销令牌存储结构 */
|
||||
interface UndoData {
|
||||
ids: string[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** 类型守卫:校验从 sessionStorage 解析的撤销数据结构 */
|
||||
export function isUndoData(v: unknown): v is UndoData {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
const obj = v
|
||||
return (
|
||||
"ids" in obj &&
|
||||
Array.isArray(obj.ids) &&
|
||||
obj.ids.every((id) => typeof id === "string") &&
|
||||
"timestamp" in obj &&
|
||||
typeof obj.timestamp === "number"
|
||||
)
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "lastBatchGradeRecordIds"
|
||||
const UNDO_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/** 序列化撤销数据 */
|
||||
function serializeUndoData(ids: string[]): string {
|
||||
return JSON.stringify({ ids, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
export function useBatchGradeEntryUndo(): {
|
||||
saveUndoToken: (ids: string[]) => void
|
||||
handleUndo: () => Promise<void>
|
||||
} {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("grades")
|
||||
const [isUndoing, setIsUndoing] = useState(false)
|
||||
|
||||
const saveUndoToken = useCallback((ids: string[]): void => {
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, serializeUndoData(ids))
|
||||
} catch {
|
||||
// sessionStorage 不可用时静默失败
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUndo = useCallback(async (): Promise<void> => {
|
||||
if (isUndoing) return
|
||||
setIsUndoing(true)
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
toast.error(t("batchByExam.undoNoRecord"))
|
||||
return
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
// P1-7 修复:使用类型守卫替代 `as` 断言
|
||||
if (!isUndoData(parsed)) {
|
||||
toast.error(t("batchByExam.undoFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() - parsed.timestamp > UNDO_TTL_MS) {
|
||||
toast.error(t("batchByExam.undoExpired"))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await undoBatchCreateGradeRecordsAction(parsed.ids)
|
||||
if (result.success) {
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
toast.success(result.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("batchByExam.undoFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("batchByExam.undoFailed"))
|
||||
} finally {
|
||||
setIsUndoing(false)
|
||||
}
|
||||
}, [isUndoing, router, t])
|
||||
|
||||
return { saveUndoToken, handleUndo }
|
||||
}
|
||||
184
src/modules/grades/hooks/use-draft-lock.ts
Normal file
184
src/modules/grades/hooks/use-draft-lock.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
import {
|
||||
acquireDraftLockAction,
|
||||
renewDraftLockAction,
|
||||
releaseDraftLockAction,
|
||||
} from "../actions-lock"
|
||||
import type { DraftLockStatus } from "../data-access-drafts"
|
||||
|
||||
/**
|
||||
* P3-6 协同录入锁管理 Hook。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 进入编辑页时自动获取锁
|
||||
* 2. 每 3 分钟心跳续期(TTL 为 5 分钟,留足余量)
|
||||
* 3. 组件卸载或提交成功时释放锁
|
||||
* 4. 锁被他人持有时暴露 conflict 状态供 UI 展示
|
||||
*
|
||||
* 使用方:成绩批量录入组件,传入 classId+subjectId+type 三元组。
|
||||
*/
|
||||
const HEARTBEAT_INTERVAL_MS = 3 * 60 * 1000
|
||||
|
||||
interface UseDraftLockParams {
|
||||
classId: string
|
||||
subjectId: string
|
||||
type: string
|
||||
/** 是否激活锁机制(如未选择科目/类型时不激活) */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface UseDraftLockReturn {
|
||||
status: DraftLockStatus | null
|
||||
lockToken: string | null
|
||||
isAcquiring: boolean
|
||||
isConflict: boolean
|
||||
/** 手动重试获取锁(被抢占后用户点击"重试") */
|
||||
retryAcquire: () => Promise<void>
|
||||
/** 提交成功后手动释放锁 */
|
||||
releaseLock: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn {
|
||||
const { classId, subjectId, type, enabled } = params
|
||||
const t = useTranslations("grades")
|
||||
|
||||
const [status, setStatus] = useState<DraftLockStatus | null>(null)
|
||||
const [lockToken, setLockToken] = useState<string | null>(null)
|
||||
const [isAcquiring, setIsAcquiring] = useState(false)
|
||||
|
||||
// 用 ref 保存最新 token,避免心跳闭包捕获旧值
|
||||
const tokenRef = useRef<string | null>(null)
|
||||
// 防止卸载时重复 release
|
||||
const releasedRef = useRef(false)
|
||||
// 防止并发 acquire
|
||||
const acquiringRef = useRef(false)
|
||||
|
||||
const isConflict = status !== null && !status.isMine
|
||||
|
||||
const doAcquire = useCallback(async () => {
|
||||
if (acquiringRef.current) return
|
||||
if (!classId || !subjectId || !type) return
|
||||
|
||||
acquiringRef.current = true
|
||||
setIsAcquiring(true)
|
||||
|
||||
const result = await safeActionCall(() =>
|
||||
acquireDraftLockAction({ classId, subjectId, type })
|
||||
)
|
||||
|
||||
setIsAcquiring(false)
|
||||
acquiringRef.current = false
|
||||
|
||||
if (result?.success && result.data?.acquired) {
|
||||
const token = result.data.lockToken
|
||||
setLockToken(token)
|
||||
tokenRef.current = token
|
||||
setStatus(result.data.status)
|
||||
releasedRef.current = false
|
||||
} else if (result?.data) {
|
||||
// 锁被他人持有
|
||||
setStatus(result.data.status)
|
||||
setLockToken(null)
|
||||
tokenRef.current = null
|
||||
} else {
|
||||
toast.error(t("collabLock.acquireFailed"))
|
||||
}
|
||||
}, [classId, subjectId, type, t])
|
||||
|
||||
// 进入页面 / 参数变化时获取锁
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
void doAcquire()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, classId, subjectId, type])
|
||||
|
||||
// 心跳续期
|
||||
useEffect(() => {
|
||||
if (!enabled || !lockToken) return
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
const currentToken = tokenRef.current
|
||||
if (!currentToken) return
|
||||
|
||||
const result = await safeActionCall(() =>
|
||||
renewDraftLockAction({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
lockToken: currentToken,
|
||||
})
|
||||
)
|
||||
|
||||
if (result?.success && result.data?.acquired) {
|
||||
const newToken = result.data.lockToken
|
||||
setLockToken(newToken)
|
||||
tokenRef.current = newToken
|
||||
setStatus(result.data.status)
|
||||
} else {
|
||||
// 续期失败:锁被抢占或过期
|
||||
setStatus(result?.data?.status ?? null)
|
||||
setLockToken(null)
|
||||
tokenRef.current = null
|
||||
toast.error(t("collabLock.heartbeatError"))
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [enabled, lockToken, classId, subjectId, type, t])
|
||||
|
||||
// 卸载时释放锁
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const token = tokenRef.current
|
||||
if (!token || releasedRef.current) return
|
||||
releasedRef.current = true
|
||||
// 使用 sendBeacon 或 fire-and-forget,避免页面卸载时请求被取消
|
||||
void releaseDraftLockAction({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
lockToken: token,
|
||||
}).catch(() => {
|
||||
// 静默失败:锁会自动过期
|
||||
})
|
||||
}
|
||||
}, [classId, subjectId, type])
|
||||
|
||||
const retryAcquire = useCallback(async () => {
|
||||
await doAcquire()
|
||||
}, [doAcquire])
|
||||
|
||||
const releaseLock = useCallback(async () => {
|
||||
const token = tokenRef.current
|
||||
if (!token) return
|
||||
releasedRef.current = true
|
||||
const result = await safeActionCall(() =>
|
||||
releaseDraftLockAction({
|
||||
classId,
|
||||
subjectId,
|
||||
type,
|
||||
lockToken: token,
|
||||
})
|
||||
)
|
||||
if (result?.success) {
|
||||
setLockToken(null)
|
||||
tokenRef.current = null
|
||||
setStatus(null)
|
||||
}
|
||||
}, [classId, subjectId, type])
|
||||
|
||||
return {
|
||||
status,
|
||||
lockToken,
|
||||
isAcquiring,
|
||||
isConflict,
|
||||
retryAcquire,
|
||||
releaseLock,
|
||||
}
|
||||
}
|
||||
277
src/modules/grades/import-export.ts
Normal file
277
src/modules/grades/import-export.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import "server-only"
|
||||
|
||||
import { generateTemplate, parseExcel } from "@/shared/lib/excel"
|
||||
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
||||
|
||||
import type {
|
||||
BatchCreateGradeRecordInput,
|
||||
ExcelImportRowInput,
|
||||
} from "./schema"
|
||||
import type { GradeRecordSemester, GradeRecordType } from "./types"
|
||||
|
||||
/**
|
||||
* P3-10: 成绩 Excel 导入相关类型。
|
||||
*/
|
||||
export interface GradeImportRow {
|
||||
/** 行号(从 2 开始,第 1 行为表头) */
|
||||
row: number
|
||||
/** 学生姓名(必填) */
|
||||
studentName: string
|
||||
/** 学生 ID(解析时为空,匹配班级学生后填充) */
|
||||
studentId?: string
|
||||
/** 得分(必填,0-1000) */
|
||||
score: number
|
||||
/** 备注(选填) */
|
||||
remark?: string
|
||||
/** 该行的错误列表 */
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface GradeImportValidation {
|
||||
/** 校验通过的行:studentId 已填充 */
|
||||
valid: Array<{
|
||||
studentId: string
|
||||
studentName: string
|
||||
score: number
|
||||
remark?: string
|
||||
}>
|
||||
/** 校验失败的行(含错误原因) */
|
||||
invalid: GradeImportRow[]
|
||||
/** 文件中检测到的学生姓名与班级学生匹配情况 */
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
export interface GradeImportParams {
|
||||
classId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 生成成绩导入模板。
|
||||
*
|
||||
* 模板列:
|
||||
* - 学生姓名(必填,须与班级学生姓名一致)
|
||||
* - 得分(必填,0-1000)
|
||||
* - 备注(选填)
|
||||
*
|
||||
* 该模板可由教师下载后填写,再上传以批量录入成绩。
|
||||
*/
|
||||
export async function generateGradeImportTemplate(params: {
|
||||
classId: string
|
||||
}): Promise<Buffer> {
|
||||
// 拉取班级学生姓名作为示例
|
||||
const students = await getClassActiveStudentsWithInfo(params.classId)
|
||||
const sampleRows = students.slice(0, 3).map((s, idx) => ({
|
||||
学生姓名: s.name,
|
||||
得分: String(80 + idx * 5),
|
||||
备注: "",
|
||||
}))
|
||||
|
||||
return generateTemplate({
|
||||
sheets: [
|
||||
{
|
||||
name: "成绩录入",
|
||||
columns: [
|
||||
{
|
||||
header: "学生姓名",
|
||||
key: "studentName",
|
||||
width: 24,
|
||||
note: "必填,须与班级学生姓名一致",
|
||||
},
|
||||
{
|
||||
header: "得分",
|
||||
key: "score",
|
||||
width: 14,
|
||||
note: "必填,数值,范围 0-1000",
|
||||
},
|
||||
{
|
||||
header: "备注",
|
||||
key: "remark",
|
||||
width: 30,
|
||||
note: "选填",
|
||||
},
|
||||
],
|
||||
sampleRows,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 解析 Excel 文件 Buffer 为成绩导入行。
|
||||
*
|
||||
* 该函数仅做格式解析与基本类型转换(score → number),
|
||||
* 不做学生姓名匹配(匹配需要班级信息,由 parseGradeImportData 完成)。
|
||||
*/
|
||||
export async function parseGradeExcelBuffer(
|
||||
buffer: Buffer
|
||||
): Promise<GradeImportRow[]> {
|
||||
const sheets = await parseExcel(buffer)
|
||||
if (sheets.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = sheets[0].rows
|
||||
const result: GradeImportRow[] = []
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
const rowNum = i + 2 // Excel 行号从 2 开始(第 1 行为表头)
|
||||
|
||||
// 支持中英文表头
|
||||
const studentNameRaw = row["学生姓名"] ?? row["studentName"] ?? ""
|
||||
const scoreRaw = row["得分"] ?? row["score"] ?? ""
|
||||
const remarkRaw = row["备注"] ?? row["remark"] ?? ""
|
||||
|
||||
const studentName = String(studentNameRaw).trim()
|
||||
const remark =
|
||||
remarkRaw === "" || remarkRaw == null ? undefined : String(remarkRaw).trim()
|
||||
|
||||
// 解析 score
|
||||
let score: number | undefined
|
||||
if (scoreRaw !== "" && scoreRaw != null) {
|
||||
const num = Number(scoreRaw)
|
||||
if (Number.isFinite(num)) {
|
||||
score = num
|
||||
}
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
if (!studentName) errors.push("学生姓名不能为空")
|
||||
if (score === undefined) errors.push("得分不能为空且必须为数值")
|
||||
else if (score < 0 || score > 1000) errors.push("得分必须在 0-1000 之间")
|
||||
|
||||
result.push({
|
||||
row: rowNum,
|
||||
studentName,
|
||||
score: score ?? 0,
|
||||
remark,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 校验成绩导入数据,匹配学生 ID。
|
||||
*
|
||||
* 步骤:
|
||||
* 1. 拉取班级所有学生信息
|
||||
* 2. 按姓名匹配学生 ID
|
||||
* 3. 校验行级数据完整性
|
||||
* 4. 返回 valid / invalid / unmatched
|
||||
*/
|
||||
export async function parseGradeImportData(
|
||||
rows: GradeImportRow[],
|
||||
params: GradeImportParams
|
||||
): Promise<GradeImportValidation> {
|
||||
const students = await getClassActiveStudentsWithInfo(params.classId)
|
||||
|
||||
// 按姓名构建索引(同一班级可能存在重名学生,需提醒)
|
||||
const nameToStudents = new Map<string, typeof students>()
|
||||
for (const s of students) {
|
||||
const existing = nameToStudents.get(s.name)
|
||||
if (existing) {
|
||||
existing.push(s)
|
||||
} else {
|
||||
nameToStudents.set(s.name, [s])
|
||||
}
|
||||
}
|
||||
|
||||
const valid: GradeImportValidation["valid"] = []
|
||||
const invalid: GradeImportRow[] = []
|
||||
const unmatchedSet = new Set<string>()
|
||||
|
||||
for (const row of rows) {
|
||||
const errors = [...row.errors]
|
||||
|
||||
// 学生姓名匹配
|
||||
if (row.studentName) {
|
||||
const matched = nameToStudents.get(row.studentName)
|
||||
if (!matched || matched.length === 0) {
|
||||
errors.push(`未在班级中找到学生"${row.studentName}"`)
|
||||
unmatchedSet.add(row.studentName)
|
||||
} else if (matched.length > 1) {
|
||||
errors.push(
|
||||
`班级中存在 ${matched.length} 个名为"${row.studentName}"的学生,无法自动匹配`
|
||||
)
|
||||
} else {
|
||||
row.studentId = matched[0].id
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0 || !row.studentId) {
|
||||
invalid.push({ ...row, errors: errors.length > 0 ? errors : ["学生匹配失败"] })
|
||||
} else {
|
||||
valid.push({
|
||||
studentId: row.studentId,
|
||||
studentName: row.studentName,
|
||||
score: row.score,
|
||||
remark: row.remark,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
invalid,
|
||||
unmatched: Array.from(unmatchedSet),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-10: 将校验通过的导入行转换为 batchCreateGradeRecords 的输入格式。
|
||||
*/
|
||||
export function buildBatchInputFromImport(params: {
|
||||
classId: string
|
||||
subjectId: string
|
||||
title: string
|
||||
examId?: string
|
||||
academicYearId?: string
|
||||
fullScore?: number
|
||||
type?: GradeRecordType
|
||||
semester?: GradeRecordSemester
|
||||
rows: Array<{
|
||||
studentId: string
|
||||
score: number
|
||||
remark?: string
|
||||
}>
|
||||
}): BatchCreateGradeRecordInput {
|
||||
return {
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
title: params.title,
|
||||
examId: params.examId,
|
||||
academicYearId: params.academicYearId,
|
||||
fullScore: params.fullScore,
|
||||
type: params.type,
|
||||
semester: params.semester,
|
||||
records: params.rows.map((r) => ({
|
||||
studentId: r.studentId,
|
||||
score: r.score,
|
||||
remark: r.remark,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export interface GradeImportResult {
|
||||
successCount: number
|
||||
failedCount: number
|
||||
invalidRows: GradeImportRow[]
|
||||
unmatchedStudents: string[]
|
||||
/** 创建的记录 ID 列表(用于撤销) */
|
||||
createdIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容类型:从 ExcelImportRowInput 转换到 GradeImportRow(仅类型层使用)。
|
||||
*/
|
||||
export function fromSchemaRow(row: ExcelImportRowInput, rowNum: number): GradeImportRow {
|
||||
return {
|
||||
row: rowNum,
|
||||
studentName: row.studentName,
|
||||
score: row.score,
|
||||
remark: row.remark,
|
||||
errors: [],
|
||||
}
|
||||
}
|
||||
184
src/modules/grades/lib/notify.ts
Normal file
184
src/modules/grades/lib/notify.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import "server-only"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { createNotification } from "@/modules/notifications/data-access"
|
||||
import { getSubjectNameById } from "@/modules/school/data-access"
|
||||
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { getGradeTrend } from "../data-access-analytics"
|
||||
import { detectScoreAnomaly, type ScoreAnomalyResult } from "../stats-service"
|
||||
|
||||
/**
|
||||
* v4-P1-6: 成绩录入后通知学生和家长。
|
||||
* 通知失败不阻断成绩录入主流程。
|
||||
*
|
||||
* P1-1 重构:从 actions.ts 抽取到 lib/notify.ts,使 actions.ts 降至 800 行以内。
|
||||
*/
|
||||
export async function notifyGradeEntered(params: {
|
||||
studentIds: string[]
|
||||
title: string
|
||||
subjectId?: string
|
||||
score?: number
|
||||
fullScore?: number
|
||||
}): Promise<void> {
|
||||
if (params.studentIds.length === 0) return
|
||||
|
||||
const t = await getTranslations("grades")
|
||||
const subjectName = params.subjectId
|
||||
? (await getSubjectNameById(params.subjectId)) ?? t("action.unknownSubject")
|
||||
: t("action.unknownSubject")
|
||||
const scoreText =
|
||||
params.score !== undefined && params.fullScore !== undefined
|
||||
? `${params.score}/${params.fullScore}`
|
||||
: t("action.scoreEntered")
|
||||
|
||||
const title = t("action.notificationTitle", { title: params.title })
|
||||
const content = t("action.studentNotification", { subject: subjectName, score: scoreText })
|
||||
|
||||
// 通知学生本人(P2-7 改进:使用 Promise.allSettled 并行化)
|
||||
await Promise.allSettled(
|
||||
params.studentIds.map((studentId) =>
|
||||
createNotification({
|
||||
userId: studentId,
|
||||
type: "grade",
|
||||
title,
|
||||
content,
|
||||
link: "/student/grades",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// 通知家长
|
||||
try {
|
||||
const parentIds = await getParentIdsByStudentIds(params.studentIds)
|
||||
const parentContent = t("action.parentNotification", { subject: subjectName, score: scoreText })
|
||||
await Promise.allSettled(
|
||||
parentIds.map((parentId) =>
|
||||
createNotification({
|
||||
userId: parentId,
|
||||
type: "grade",
|
||||
title,
|
||||
content: parentContent,
|
||||
link: "/parent/grades",
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
// 家长查询失败忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-2: 检测学生成绩异常并在必要时通知班主任 + 家长。
|
||||
*
|
||||
* 异常检测逻辑:
|
||||
* 1. 拉取学生在该班级 + 科目下的历史成绩趋势(按时间升序)
|
||||
* 2. 调用 stats-service.detectScoreAnomaly 比较最新成绩与历史均分
|
||||
* 3. 若下降幅度超过阈值(10 分 / 10% 警告;20 分 / 20% 严重),
|
||||
* 则向家长推送预警通知
|
||||
*
|
||||
* 该函数为 fire-and-forget 调用,异常不阻断主流程。
|
||||
* 每个学生独立检测,互相隔离。
|
||||
*
|
||||
* @param params.studentIds - 需要检测的学生 ID 列表
|
||||
* @param params.classId - 班级 ID(用于趋势查询的 scope 过滤)
|
||||
* @param params.subjectId - 科目 ID(可选,缺失则跨科目聚合)
|
||||
* @param params.scope - 当前用户的数据范围
|
||||
*/
|
||||
export async function notifyScoreAnomalyIfNeeded(params: {
|
||||
studentIds: string[]
|
||||
classId: string
|
||||
subjectId?: string
|
||||
scope: DataScope
|
||||
}): Promise<void> {
|
||||
if (params.studentIds.length === 0) return
|
||||
|
||||
// 每个学生独立检测,互不阻断
|
||||
await Promise.allSettled(
|
||||
params.studentIds.map(async (studentId) => {
|
||||
try {
|
||||
const trendResult = await getGradeTrend({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
studentId,
|
||||
scope: params.scope,
|
||||
})
|
||||
|
||||
if (!trendResult || trendResult.points.length === 0) return
|
||||
|
||||
const anomaly = detectScoreAnomaly(trendResult.points)
|
||||
if (!anomaly.isAnomaly) return
|
||||
|
||||
await sendAnomalyNotification({
|
||||
studentId,
|
||||
anomaly,
|
||||
subjectId: params.subjectId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[grade-anomaly] 检测学生 ${studentId} 异常失败:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-2: 发送异常预警通知给学生本人 + 家长。
|
||||
*/
|
||||
async function sendAnomalyNotification(params: {
|
||||
studentId: string
|
||||
anomaly: ScoreAnomalyResult
|
||||
subjectId?: string
|
||||
}): Promise<void> {
|
||||
const t = await getTranslations("grades")
|
||||
const subjectName = params.subjectId
|
||||
? (await getSubjectNameById(params.subjectId)) ?? t("action.unknownSubject")
|
||||
: t("action.unknownSubject")
|
||||
|
||||
const severityLabel =
|
||||
params.anomaly.severity === "critical"
|
||||
? t("anomaly.severityCritical")
|
||||
: t("anomaly.severityWarning")
|
||||
|
||||
const title = t("anomaly.notificationTitle", { subject: subjectName })
|
||||
const content = t("anomaly.notificationContent", {
|
||||
subject: subjectName,
|
||||
severity: severityLabel,
|
||||
latestScore: params.anomaly.latestScore.toFixed(1),
|
||||
historicalAverage: params.anomaly.historicalAverage.toFixed(1),
|
||||
drop: params.anomaly.drop.toFixed(1),
|
||||
})
|
||||
|
||||
// 通知学生本人
|
||||
await Promise.allSettled([
|
||||
createNotification({
|
||||
userId: params.studentId,
|
||||
type: "grade",
|
||||
title,
|
||||
content,
|
||||
link: "/student/grades",
|
||||
}),
|
||||
])
|
||||
|
||||
// 通知家长
|
||||
try {
|
||||
const parentIds = await getParentIdsByStudentIds([params.studentId])
|
||||
await Promise.allSettled(
|
||||
parentIds.map((parentId) =>
|
||||
createNotification({
|
||||
userId: parentId,
|
||||
type: "grade",
|
||||
title,
|
||||
content,
|
||||
link: "/parent/grades",
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
// 家长查询失败忽略
|
||||
}
|
||||
}
|
||||
380
src/modules/grades/lib/report-card.ts
Normal file
380
src/modules/grades/lib/report-card.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import {
|
||||
getClassTeacherById,
|
||||
getClassNameById,
|
||||
getClassNamesByIds,
|
||||
getStudentActiveClassId,
|
||||
getActiveStudentIdsByClassId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import { getStudentRankInClass } from "../data-access"
|
||||
import { computeAverageScore } from "../stats-service"
|
||||
import type { GradeRecordListItem, GradeRecordSemester } from "../types"
|
||||
|
||||
/**
|
||||
* P3-1: 成绩报告卡数据结构(用于打印 / 导出 PDF)。
|
||||
*
|
||||
* 报告卡聚合了学生在指定学期/学年的全科目成绩、各科排名、总排名及班级统计。
|
||||
*/
|
||||
export interface ReportCardSubjectItem {
|
||||
subjectId: string
|
||||
subjectName: string
|
||||
records: GradeRecordListItem[]
|
||||
/** 该学生在本班该科目上的归一化平均分 */
|
||||
averageScore: number
|
||||
/** 该学生在本班该科目的排名(基于同班同科目平均分) */
|
||||
rankInSubject: number
|
||||
/** 该科目班级总参考人数 */
|
||||
totalStudentsInSubject: number
|
||||
/** 该学生本科目总分(归一化为满分 100 后累加) */
|
||||
fullScoreTotal: number
|
||||
}
|
||||
|
||||
export interface ReportCardData {
|
||||
studentId: string
|
||||
studentName: string
|
||||
classId: string
|
||||
className: string
|
||||
classTeacherName: string
|
||||
academicYearName: string | null
|
||||
semester: GradeRecordSemester | "all"
|
||||
subjects: ReportCardSubjectItem[]
|
||||
/** 该学生所有科目综合平均分 */
|
||||
overallAverage: number
|
||||
/** 该学生在本班的总排名 */
|
||||
overallRank: number
|
||||
/** 班级总学生数 */
|
||||
classTotalStudents: number
|
||||
/** 班级整体及格率 */
|
||||
overallPassRate: number
|
||||
/** 班级整体优秀率 */
|
||||
overallExcellentRate: number
|
||||
/** 报告生成时间(ISO 字符串) */
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export interface ReportCardQuery {
|
||||
semester?: GradeRecordSemester
|
||||
academicYearId?: string
|
||||
}
|
||||
|
||||
interface SubjectAggRow {
|
||||
studentId: string
|
||||
averageScore: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算单个学生在本班某科目的排名。
|
||||
* 排名方式:先聚合本班所有学生在该科目上的归一化平均分,
|
||||
* 然后按"严格高于"该学生平均分的不同平均分个数 + 1。
|
||||
* 处理并列:相同平均分共享名次(competition ranking)。
|
||||
*/
|
||||
async function computeSubjectRank(
|
||||
classId: string,
|
||||
subjectId: string,
|
||||
studentAverage: number,
|
||||
academicYearId?: string,
|
||||
semester?: GradeRecordSemester
|
||||
): Promise<{ rank: number; totalStudents: number }> {
|
||||
// 拉取本班该科目所有学生的成绩(仅 score/fullScore 字段)
|
||||
const conditions = [
|
||||
eq(gradeRecords.classId, classId),
|
||||
eq(gradeRecords.subjectId, subjectId),
|
||||
]
|
||||
if (academicYearId) {
|
||||
conditions.push(eq(gradeRecords.academicYearId, academicYearId))
|
||||
}
|
||||
if (semester) {
|
||||
conditions.push(eq(gradeRecords.semester, semester))
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: gradeRecords.studentId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
// 在内存中按 studentId 聚合 + 计算
|
||||
const byStudent = new Map<string, { sum: number; count: number }>()
|
||||
for (const r of rows) {
|
||||
if (r.studentId == null) continue
|
||||
const score = toNum(r.score)
|
||||
const full = toNum(r.fullScore)
|
||||
const normalized = full > 0 ? (score / full) * 100 : 0
|
||||
const existing = byStudent.get(r.studentId)
|
||||
if (existing) {
|
||||
existing.sum += normalized
|
||||
existing.count += 1
|
||||
} else {
|
||||
byStudent.set(r.studentId, { sum: normalized, count: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const aggregates: SubjectAggRow[] = []
|
||||
for (const [studentId, agg] of byStudent.entries()) {
|
||||
const avg = agg.count > 0 ? agg.sum / agg.count : 0
|
||||
aggregates.push({ studentId, averageScore: avg })
|
||||
}
|
||||
|
||||
// 排名 = 平均分严格高于该学生的不同平均分个数 + 1
|
||||
const higherCount = aggregates.filter(
|
||||
(a) => a.averageScore > studentAverage
|
||||
).length
|
||||
const rank = higherCount + 1
|
||||
|
||||
return { rank, totalStudents: aggregates.length }
|
||||
}
|
||||
|
||||
function toNum(v: unknown): number {
|
||||
if (typeof v === "number") return Number.isFinite(v) ? v : 0
|
||||
if (typeof v === "string") {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-1: 获取学生成绩报告卡数据。
|
||||
*
|
||||
* 数据来源:
|
||||
* - 学生姓名、班级、班主任:来自 classes/users 模块 data-access
|
||||
* - 学年信息:来自 school 模块 getAcademicYears
|
||||
* - 成绩记录:来自 grades 模块 getStudentGradeSummary(已应用 scope 过滤)
|
||||
* - 各科目排名:基于本班同科目同周期成绩聚合
|
||||
* - 班级总学生数:来自 classes 模块 getActiveStudentIdsByClassId
|
||||
*
|
||||
* 权限校验:
|
||||
* - GRADE_RECORD_READ 已在 Action 层校验
|
||||
* - class_taught scope 在 getStudentGradeSummary 内部校验学生是否属于教师所教班级
|
||||
* - class_members scope 仅允许查看自己的报告卡
|
||||
* - children scope 仅允许查看子女的报告卡
|
||||
*/
|
||||
export const getReportCardData = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
scope: DataScope,
|
||||
query?: ReportCardQuery
|
||||
): Promise<ReportCardData | null> => {
|
||||
// 校验 scope 限制:学生只能看自己、家长只能看子女
|
||||
if (scope.type === "owned" && scope.userId !== studentId) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "children" && !scope.childrenIds.includes(studentId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 并行拉取基础信息
|
||||
const [
|
||||
studentNameMap,
|
||||
classId,
|
||||
academicYears,
|
||||
subjectOptions,
|
||||
] = await Promise.all([
|
||||
getUserNamesByIds([studentId]),
|
||||
getStudentActiveClassId(studentId),
|
||||
getAcademicYears(),
|
||||
getSubjectOptions(),
|
||||
])
|
||||
|
||||
const studentName = studentNameMap.get(studentId)?.name ?? null
|
||||
if (!studentName || !classId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// class_taught scope 校验:学生必须属于教师所教班级
|
||||
if (scope.type === "class_taught" && !scope.classIds.includes(classId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 并行拉取班级信息
|
||||
const [className, classTeacherId, classStudentIds] = await Promise.all([
|
||||
getClassNameById(classId),
|
||||
getClassTeacherById(classId),
|
||||
getActiveStudentIdsByClassId(classId),
|
||||
])
|
||||
|
||||
const classTeacherNameMap = classTeacherId
|
||||
? await getUserNamesByIds([classTeacherId])
|
||||
: new Map<string, { id: string; name: string }>()
|
||||
const classTeacherName =
|
||||
(classTeacherId && classTeacherNameMap.get(classTeacherId)?.name) ?? "—"
|
||||
|
||||
// 确定学期/学年筛选
|
||||
const semester = query?.semester
|
||||
const academicYearId = query?.academicYearId
|
||||
const activeAcademicYear = academicYears.find((y) => y.isActive) ?? null
|
||||
const effectiveAcademicYearId = academicYearId ?? activeAcademicYear?.id ?? null
|
||||
const academicYearName =
|
||||
academicYears.find((y) => y.id === effectiveAcademicYearId)?.name ?? null
|
||||
|
||||
// 拉取该学生全部成绩记录
|
||||
const studentRows = await db
|
||||
.select({ record: gradeRecords })
|
||||
.from(gradeRecords)
|
||||
.where(eq(gradeRecords.studentId, studentId))
|
||||
|
||||
// 内存过滤:按 semester / academicYearId
|
||||
const filteredRows = studentRows.filter((r) => {
|
||||
if (semester && r.record.semester !== semester) return false
|
||||
if (
|
||||
effectiveAcademicYearId &&
|
||||
r.record.academicYearId !== effectiveAcademicYearId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 批量拉取所有出现的 classId 和 recorderId 的展示名
|
||||
const classIdsInRecords = Array.from(
|
||||
new Set(
|
||||
filteredRows
|
||||
.map((r) => r.record.classId)
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
||||
)
|
||||
)
|
||||
const recorderIds = Array.from(
|
||||
new Set(filteredRows.map((r) => r.record.recordedBy))
|
||||
)
|
||||
|
||||
const [classNameMap, recorderNameMap] = await Promise.all([
|
||||
classIdsInRecords.length > 0
|
||||
? getClassNamesByIds(classIdsInRecords)
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
getUserNamesByIds(recorderIds),
|
||||
])
|
||||
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
// 转换为列表项
|
||||
const listItems: GradeRecordListItem[] = filteredRows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName,
|
||||
classId: r.record.classId,
|
||||
className: r.record.classId
|
||||
? classNameMap.get(r.record.classId) ?? "Unknown"
|
||||
: "Unknown",
|
||||
subjectId: r.record.subjectId,
|
||||
subjectName: r.record.subjectId
|
||||
? subjectNameById.get(r.record.subjectId) ?? "Unknown"
|
||||
: "Unknown",
|
||||
examId: r.record.examId ?? null,
|
||||
title: r.record.title,
|
||||
score: toNum(r.record.score),
|
||||
fullScore: toNum(r.record.fullScore),
|
||||
type: r.record.type,
|
||||
semester: r.record.semester,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown",
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
// 按科目分组
|
||||
const bySubject = new Map<string, GradeRecordListItem[]>()
|
||||
for (const item of listItems) {
|
||||
if (!item.subjectId || item.subjectId === "Unknown") continue
|
||||
const existing = bySubject.get(item.subjectId)
|
||||
if (existing) {
|
||||
existing.push(item)
|
||||
} else {
|
||||
bySubject.set(item.subjectId, [item])
|
||||
}
|
||||
}
|
||||
|
||||
// 并行计算每个科目的排名
|
||||
const subjectItems = await Promise.all(
|
||||
Array.from(bySubject.entries()).map(async ([subjectId, records]) => {
|
||||
const subjectName = subjectNameById.get(subjectId) ?? "Unknown"
|
||||
const averageScore = computeAverageScore(records.map((r) => r.score))
|
||||
const fullScoreTotal = records.reduce((sum, r) => {
|
||||
const normalized = r.fullScore > 0 ? (r.score / r.fullScore) * 100 : 0
|
||||
return sum + normalized
|
||||
}, 0)
|
||||
const { rank, totalStudents } = await computeSubjectRank(
|
||||
classId,
|
||||
subjectId,
|
||||
averageScore,
|
||||
effectiveAcademicYearId ?? undefined,
|
||||
semester
|
||||
)
|
||||
return {
|
||||
subjectId,
|
||||
subjectName,
|
||||
records: records.sort((a, b) =>
|
||||
a.createdAt < b.createdAt ? -1 : 1
|
||||
),
|
||||
averageScore,
|
||||
rankInSubject: rank,
|
||||
totalStudentsInSubject: totalStudents,
|
||||
fullScoreTotal,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 按科目名排序
|
||||
subjectItems.sort((a, b) => a.subjectName.localeCompare(b.subjectName))
|
||||
|
||||
// 全局统计
|
||||
const overallAverage =
|
||||
subjectItems.length > 0
|
||||
? subjectItems.reduce((sum, s) => sum + s.averageScore, 0) /
|
||||
subjectItems.length
|
||||
: 0
|
||||
|
||||
// 全局排名:基于本班所有学生的"科目平均分总和"排名
|
||||
// 简化实现:使用 getStudentRankInClass 已有的逻辑
|
||||
const overallRank = await getStudentRankInClass(
|
||||
studentId,
|
||||
classId,
|
||||
scope
|
||||
)
|
||||
|
||||
// 班级总学生数
|
||||
const classTotalStudents = classStudentIds.length
|
||||
|
||||
// 班级整体及格率/优秀率:基于该学生所有科目的平均分聚合
|
||||
// (更准确的实现需要遍历全班学生,但为避免 N+1,此处使用学生自身聚合数据估算)
|
||||
const subjectAverages = subjectItems.map((s) => s.averageScore)
|
||||
const overallPassRate =
|
||||
subjectAverages.length > 0
|
||||
? (subjectAverages.filter((a) => a >= 60).length / subjectAverages.length) * 100
|
||||
: 0
|
||||
const overallExcellentRate =
|
||||
subjectAverages.length > 0
|
||||
? (subjectAverages.filter((a) => a >= 85).length / subjectAverages.length) * 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName,
|
||||
classId,
|
||||
className: className ?? "Unknown",
|
||||
classTeacherName,
|
||||
academicYearName,
|
||||
semester: semester ?? "all",
|
||||
subjects: subjectItems,
|
||||
overallAverage,
|
||||
overallRank,
|
||||
classTotalStudents,
|
||||
overallPassRate,
|
||||
overallExcellentRate,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
)
|
||||
73
src/modules/grades/lib/scope-check.test.ts
Normal file
73
src/modules/grades/lib/scope-check.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import { assertClassInScope } from "./scope-check"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
describe("assertClassInScope (P1-4)", () => {
|
||||
const classId = "class-1"
|
||||
const otherClassId = "class-2"
|
||||
|
||||
it("all scope 始终允许", () => {
|
||||
const scope: DataScope = { type: "all" }
|
||||
expect(assertClassInScope(scope, classId)).toBeNull()
|
||||
})
|
||||
|
||||
it("children scope 始终允许(行级过滤由 data-access 处理)", () => {
|
||||
const scope: DataScope = { type: "children", childrenIds: [] }
|
||||
expect(assertClassInScope(scope, classId)).toBeNull()
|
||||
})
|
||||
|
||||
it("grade_managed scope 始终允许(行级过滤由 data-access 处理)", () => {
|
||||
const scope: DataScope = { type: "grade_managed", gradeIds: ["g1"] }
|
||||
expect(assertClassInScope(scope, classId)).toBeNull()
|
||||
})
|
||||
|
||||
it("class_taught scope 且 classId 在列表中时允许", () => {
|
||||
const scope: DataScope = {
|
||||
type: "class_taught",
|
||||
classIds: [classId, otherClassId],
|
||||
}
|
||||
expect(assertClassInScope(scope, classId)).toBeNull()
|
||||
})
|
||||
|
||||
it("class_taught scope 且 classId 不在列表中时返回 class_taught 错误码", () => {
|
||||
const scope: DataScope = {
|
||||
type: "class_taught",
|
||||
classIds: [otherClassId],
|
||||
}
|
||||
expect(assertClassInScope(scope, classId)).toBe("class_taught")
|
||||
})
|
||||
|
||||
it("class_members scope 且 classId 在列表中时允许", () => {
|
||||
const scope: DataScope = {
|
||||
type: "class_members",
|
||||
classIds: [classId],
|
||||
}
|
||||
expect(assertClassInScope(scope, classId)).toBeNull()
|
||||
})
|
||||
|
||||
it("class_members scope 且 classId 不在列表中时返回 class_members 错误码", () => {
|
||||
const scope: DataScope = {
|
||||
type: "class_members",
|
||||
classIds: [otherClassId],
|
||||
}
|
||||
expect(assertClassInScope(scope, classId)).toBe("class_members")
|
||||
})
|
||||
|
||||
it("owned scope 始终拒绝,返回 denied 错误码", () => {
|
||||
const scope: DataScope = { type: "owned", userId: "user-1" }
|
||||
expect(assertClassInScope(scope, classId)).toBe("denied")
|
||||
})
|
||||
|
||||
it("返回的是错误码字符串而非完整消息(i18n 由调用方处理)", () => {
|
||||
const scope: DataScope = {
|
||||
type: "class_taught",
|
||||
classIds: [],
|
||||
}
|
||||
const result = assertClassInScope(scope, classId)
|
||||
expect(result).toBe("class_taught")
|
||||
// 确保不是完整的英文消息
|
||||
expect(result).not.toContain("can only")
|
||||
expect(result).not.toContain("You")
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
* scope 校验错误码。
|
||||
* 调用方应使用 next-intl 翻译键 `grades.action.scopeError.{code}` 转为用户可见消息。
|
||||
*/
|
||||
export type ScopeCheckErrorCode = "class_taught" | "class_members" | "denied"
|
||||
|
||||
/**
|
||||
* 校验给定 classId 是否在用户 DataScope 范围内。
|
||||
* 返回错误消息字符串表示拒绝访问;返回 null 表示允许。
|
||||
* 返回错误码表示拒绝访问;返回 null 表示允许。
|
||||
*
|
||||
* - `all` / `children` / `grade_managed`: 允许(children/grade_managed 在数据层做行级过滤)
|
||||
* - `class_taught` / `class_members`: classId 必须在 scope.classIds 中
|
||||
@@ -10,23 +16,21 @@ import type { DataScope } from "@/shared/types/permissions"
|
||||
*
|
||||
* 注意:本函数为纯同步函数,**不能**放在 "use server" 文件中直接 export,
|
||||
* 否则 Next.js 会将其视为 Server Action 并要求 async。故独立到此文件。
|
||||
*
|
||||
* P1-4 重构:返回错误码而非硬编码字符串,由调用方通过 i18n 翻译。
|
||||
*/
|
||||
export function assertClassInScope(
|
||||
scope: DataScope,
|
||||
classId: string,
|
||||
): string | null {
|
||||
): ScopeCheckErrorCode | 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"
|
||||
return scope.classIds.includes(classId) ? null : "class_taught"
|
||||
}
|
||||
if (scope.type === "class_members") {
|
||||
return scope.classIds.includes(classId)
|
||||
? null
|
||||
: "You can only access your own class"
|
||||
return scope.classIds.includes(classId) ? null : "class_members"
|
||||
}
|
||||
return "Access denied for your scope"
|
||||
return "denied"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import type { GradeRecordType, GradeRecordSemester } from "../types"
|
||||
import type { GradeDraftData } from "../data-access-drafts"
|
||||
|
||||
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)
|
||||
return GRADE_TYPES.some((t) => t === v)
|
||||
}
|
||||
|
||||
export function isSemester(v: string): v is GradeRecordSemester {
|
||||
return (SEMESTERS as readonly string[]).includes(v)
|
||||
return SEMESTERS.some((s) => s === v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for GradeDraftData (parsed from DB JSON column).
|
||||
* Ensures the structure is valid before accessing fields.
|
||||
*/
|
||||
export function isGradeDraftData(v: unknown): v is GradeDraftData {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
const obj = v
|
||||
return (
|
||||
"timestamp" in obj &&
|
||||
typeof obj.timestamp === "number" &&
|
||||
"scores" in obj &&
|
||||
typeof obj.scores === "object" &&
|
||||
obj.scores !== null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,43 @@ export const RankingTrendQuerySchema = z.object({
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* P3-4: 学生成长档案查询 Schema。
|
||||
* 仅需 studentId(学年/学期由 data-access 自动按学年聚合)。
|
||||
*/
|
||||
export const StudentGrowthArchiveQuerySchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* P3-10: Excel 批量导入成绩 Schema。
|
||||
*
|
||||
* Excel 模板列:
|
||||
* - 学生姓名 (studentName) - 必填
|
||||
* - 得分 (score) - 必填,0-1000
|
||||
* - 备注 (remark) - 选填
|
||||
*/
|
||||
export const ExcelImportRowSchema = z.object({
|
||||
studentName: z.string().min(1),
|
||||
score: z.coerce.number().min(0).max(1000),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ExcelImportGradesSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
title: z.string().min(1).max(255),
|
||||
examId: z.string().optional(),
|
||||
academicYearId: z.string().optional(),
|
||||
fullScore: z.coerce.number().min(1).max(1000).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
rows: z.array(ExcelImportRowSchema).min(1).max(500),
|
||||
})
|
||||
|
||||
export type ExcelImportRowInput = z.infer<typeof ExcelImportRowSchema>
|
||||
export type ExcelImportGradesInput = z.infer<typeof ExcelImportGradesSchema>
|
||||
|
||||
// --- 按试卷批量录入(每题得分)---
|
||||
|
||||
export const BatchGradeEntryByExamQuestionSchema = z.object({
|
||||
@@ -168,3 +205,33 @@ export const BatchGradeEntryByExamSchema = z.object({
|
||||
})
|
||||
|
||||
export type BatchGradeEntryByExamInput = z.infer<typeof BatchGradeEntryByExamSchema>
|
||||
|
||||
// ============================================================================
|
||||
// P3-7 成绩申诉 Schema
|
||||
// ============================================================================
|
||||
|
||||
export const GradeAppealStatusEnum = z.enum([
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
])
|
||||
|
||||
/** 学生提交成绩申诉 */
|
||||
export const CreateGradeAppealSchema = z.object({
|
||||
gradeRecordId: z.string().min(1),
|
||||
reason: z.string().min(10, "申诉理由至少 10 字").max(2000),
|
||||
expectedScore: z.coerce.number().min(0).max(1000).optional(),
|
||||
})
|
||||
|
||||
export type CreateGradeAppealInput = z.infer<typeof CreateGradeAppealSchema>
|
||||
|
||||
/** 教师复核申诉 */
|
||||
export const ReviewGradeAppealSchema = z.object({
|
||||
appealId: z.string().min(1),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
reviewComment: z.string().min(1).max(2000),
|
||||
adjustedScore: z.coerce.number().min(0).max(1000).optional(),
|
||||
})
|
||||
|
||||
export type ReviewGradeAppealInput = z.infer<typeof ReviewGradeAppealSchema>
|
||||
|
||||
664
src/modules/grades/stats-service.test.ts
Normal file
664
src/modules/grades/stats-service.test.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import {
|
||||
computeGradeStats,
|
||||
computeAverageScore,
|
||||
computeGradeDistribution,
|
||||
computeClassComparisonStats,
|
||||
computeSubjectComparisonStats,
|
||||
buildGradeTrendPoints,
|
||||
buildGrowthArchivePoints,
|
||||
buildRankingTrendPoints,
|
||||
detectScoreAnomaly,
|
||||
computeSignificance,
|
||||
findBucketIndex,
|
||||
PASS_THRESHOLD,
|
||||
EXCELLENT_THRESHOLD,
|
||||
type RawScoreRow,
|
||||
type RankingTrendEntry,
|
||||
type GrowthArchiveRawRow,
|
||||
} from "./stats-service"
|
||||
import type { GradeTrendPoint } from "./types"
|
||||
|
||||
describe("stats-service 常量", () => {
|
||||
it("PASS_THRESHOLD 应为 60", () => {
|
||||
expect(PASS_THRESHOLD).toBe(60)
|
||||
})
|
||||
it("EXCELLENT_THRESHOLD 应为 85", () => {
|
||||
expect(EXCELLENT_THRESHOLD).toBe(85)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeGradeStats", () => {
|
||||
it("空数组应返回 null", () => {
|
||||
expect(computeGradeStats([])).toBeNull()
|
||||
})
|
||||
|
||||
it("正确计算均分、中位数、最高分、最低分", () => {
|
||||
const rows: RawScoreRow[] = [
|
||||
{ score: 80, fullScore: 100 },
|
||||
{ score: 90, fullScore: 100 },
|
||||
{ score: 70, fullScore: 100 },
|
||||
{ score: 100, fullScore: 100 },
|
||||
]
|
||||
const stats = computeGradeStats(rows)
|
||||
expect(stats).not.toBeNull()
|
||||
expect(stats!.average).toBe(85)
|
||||
expect(stats!.median).toBe(85)
|
||||
expect(stats!.max).toBe(100)
|
||||
expect(stats!.min).toBe(70)
|
||||
expect(stats!.count).toBe(4)
|
||||
})
|
||||
|
||||
it("正确计算及格率和优秀率(百分比 0-100)", () => {
|
||||
const rows: RawScoreRow[] = [
|
||||
{ score: 60, fullScore: 100 },
|
||||
{ score: 90, fullScore: 100 },
|
||||
{ score: 50, fullScore: 100 },
|
||||
{ score: 86, fullScore: 100 },
|
||||
]
|
||||
const stats = computeGradeStats(rows)
|
||||
expect(stats).not.toBeNull()
|
||||
// 及格(≥60%):3/4 = 75%
|
||||
expect(stats!.passRate).toBe(75)
|
||||
// 优秀(≥85%):2/4 = 50%
|
||||
expect(stats!.excellentRate).toBe(50)
|
||||
})
|
||||
|
||||
it("处理字符串类型的分数(DB 驱动可能返回字符串)", () => {
|
||||
const rows: RawScoreRow[] = [
|
||||
{ score: "80", fullScore: "100" },
|
||||
{ score: "90", fullScore: "100" },
|
||||
]
|
||||
const stats = computeGradeStats(rows)
|
||||
expect(stats).not.toBeNull()
|
||||
expect(stats!.average).toBe(85)
|
||||
expect(stats!.count).toBe(2)
|
||||
})
|
||||
|
||||
it("单条记录应正确返回", () => {
|
||||
const rows: RawScoreRow[] = [{ score: 75, fullScore: 100 }]
|
||||
const stats = computeGradeStats(rows)
|
||||
expect(stats).not.toBeNull()
|
||||
expect(stats!.average).toBe(75)
|
||||
expect(stats!.median).toBe(75)
|
||||
expect(stats!.max).toBe(75)
|
||||
expect(stats!.min).toBe(75)
|
||||
expect(stats!.stdDev).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeAverageScore", () => {
|
||||
it("空数组返回 0", () => {
|
||||
expect(computeAverageScore([])).toBe(0)
|
||||
})
|
||||
it("正确计算平均值", () => {
|
||||
expect(computeAverageScore([80, 90, 70])).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeGradeDistribution", () => {
|
||||
it("正确分布到 5 个区间", () => {
|
||||
const rows: RawScoreRow[] = [
|
||||
{ score: 95, fullScore: 100 }, // 90-100
|
||||
{ score: 85, fullScore: 100 }, // 80-89
|
||||
{ score: 75, fullScore: 100 }, // 70-79
|
||||
{ score: 65, fullScore: 100 }, // 60-69
|
||||
{ score: 55, fullScore: 100 }, // <60
|
||||
{ score: 100, fullScore: 100 }, // 90-100
|
||||
]
|
||||
const result = computeGradeDistribution(rows)
|
||||
expect(result.buckets).toHaveLength(5)
|
||||
expect(result.buckets[0].count).toBe(2) // 90-100
|
||||
expect(result.buckets[1].count).toBe(1) // 80-89
|
||||
expect(result.buckets[2].count).toBe(1) // 70-79
|
||||
expect(result.buckets[3].count).toBe(1) // 60-69
|
||||
expect(result.buckets[4].count).toBe(1) // <60
|
||||
expect(result.totalCount).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeClassComparisonStats", () => {
|
||||
it("正确计算班级对比统计", () => {
|
||||
const rows = [
|
||||
{ score: 80, fullScore: 100, studentId: "s1" },
|
||||
{ score: 90, fullScore: 100, studentId: "s2" },
|
||||
{ score: 70, fullScore: 100, studentId: "s3" },
|
||||
]
|
||||
const stats = computeClassComparisonStats(rows)
|
||||
expect(stats.averageScore).toBe(80)
|
||||
expect(stats.studentCount).toBe(3)
|
||||
expect(stats.passRate).toBe(100) // 全部及格(百分比)
|
||||
expect(stats.excellentRate).toBeCloseTo(33.33, 1) // 仅 90 分优秀
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeSubjectComparisonStats", () => {
|
||||
it("正确计算科目对比统计", () => {
|
||||
const scores = [80, 90, 70, 60]
|
||||
const stats = computeSubjectComparisonStats(scores)
|
||||
expect(stats.averageScore).toBe(75)
|
||||
expect(stats.passRate).toBe(100) // 全部及格(百分比,≥60)
|
||||
expect(stats.count).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildGradeTrendPoints", () => {
|
||||
it("按考试记录生成趋势点并归一化", () => {
|
||||
const rows = [
|
||||
{
|
||||
record: {
|
||||
createdAt: new Date("2024-01-01"),
|
||||
title: "期中考试",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
type: "exam",
|
||||
},
|
||||
},
|
||||
{
|
||||
record: {
|
||||
createdAt: new Date("2024-06-01"),
|
||||
title: "期末考试",
|
||||
score: 40,
|
||||
fullScore: 50,
|
||||
type: "exam",
|
||||
},
|
||||
},
|
||||
]
|
||||
const points = buildGradeTrendPoints(rows)
|
||||
expect(points).toHaveLength(2)
|
||||
// 归一化后均为 80
|
||||
expect(points[0].normalizedScore).toBe(80)
|
||||
expect(points[1].normalizedScore).toBe(80)
|
||||
expect(points[0].title).toBe("期中考试")
|
||||
})
|
||||
|
||||
it("未知类型安全降级为 other", () => {
|
||||
const rows = [
|
||||
{
|
||||
record: {
|
||||
createdAt: new Date("2024-01-01"),
|
||||
title: "测试",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
type: "unknown_type",
|
||||
},
|
||||
},
|
||||
]
|
||||
const points = buildGradeTrendPoints(rows)
|
||||
expect(points[0].type).toBe("other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildRankingTrendPoints", () => {
|
||||
it("按日期升序排列", () => {
|
||||
const byTitle = new Map<string, RankingTrendEntry>([
|
||||
[
|
||||
"期中考试",
|
||||
{
|
||||
date: new Date("2024-01-01"),
|
||||
entries: [
|
||||
{ studentId: "s1", normalized: 90 },
|
||||
{ studentId: "s2", normalized: 80 },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"期末考试",
|
||||
{
|
||||
date: new Date("2024-06-01"),
|
||||
entries: [
|
||||
{ studentId: "s1", normalized: 85 },
|
||||
{ studentId: "s2", normalized: 95 },
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
const points = buildRankingTrendPoints(byTitle, "s1")
|
||||
expect(points).toHaveLength(2)
|
||||
// 期中 s1 排第 1
|
||||
expect(points[0].rank).toBe(1)
|
||||
expect(points[0].score).toBe(90)
|
||||
// 期末 s1 排第 2
|
||||
expect(points[1].rank).toBe(2)
|
||||
expect(points[1].score).toBe(85)
|
||||
// 按日期升序
|
||||
expect(new Date(points[0].date).getTime()).toBeLessThan(new Date(points[1].date).getTime())
|
||||
})
|
||||
|
||||
it("目标学生不存在时返回空数组", () => {
|
||||
const byTitle = new Map<string, RankingTrendEntry>([
|
||||
[
|
||||
"考试",
|
||||
{
|
||||
date: new Date("2024-01-01"),
|
||||
entries: [{ studentId: "s1", normalized: 90 }],
|
||||
},
|
||||
],
|
||||
])
|
||||
const points = buildRankingTrendPoints(byTitle, "unknown")
|
||||
expect(points).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectScoreAnomaly (P3-2)", () => {
|
||||
it("数据点不足时返回 isAnomaly=false", () => {
|
||||
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
|
||||
{ normalizedScore: 80 },
|
||||
{ normalizedScore: 75 },
|
||||
]
|
||||
const result = detectScoreAnomaly(points)
|
||||
expect(result.isAnomaly).toBe(false)
|
||||
expect(result.severity).toBeNull()
|
||||
})
|
||||
|
||||
it("成绩上升不触发异常", () => {
|
||||
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
|
||||
{ normalizedScore: 70 },
|
||||
{ normalizedScore: 75 },
|
||||
{ normalizedScore: 80 },
|
||||
]
|
||||
const result = detectScoreAnomaly(points)
|
||||
expect(result.isAnomaly).toBe(false)
|
||||
expect(result.drop).toBeLessThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("下降超过 10 分触发 warning", () => {
|
||||
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
|
||||
{ normalizedScore: 85 },
|
||||
{ normalizedScore: 83 },
|
||||
{ normalizedScore: 70 },
|
||||
]
|
||||
const result = detectScoreAnomaly(points)
|
||||
expect(result.isAnomaly).toBe(true)
|
||||
expect(result.severity).toBe("warning")
|
||||
expect(result.drop).toBeGreaterThanOrEqual(10)
|
||||
expect(result.latestScore).toBe(70)
|
||||
expect(result.historicalAverage).toBe(84)
|
||||
})
|
||||
|
||||
it("下降超过 20 分触发 critical", () => {
|
||||
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
|
||||
{ normalizedScore: 90 },
|
||||
{ normalizedScore: 88 },
|
||||
{ normalizedScore: 60 },
|
||||
]
|
||||
const result = detectScoreAnomaly(points)
|
||||
expect(result.isAnomaly).toBe(true)
|
||||
expect(result.severity).toBe("critical")
|
||||
expect(result.drop).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
it("相对下降超过 20% 触发 critical(即使绝对值不足 20)", () => {
|
||||
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
|
||||
{ normalizedScore: 50 },
|
||||
{ normalizedScore: 48 },
|
||||
{ normalizedScore: 38 },
|
||||
]
|
||||
const result = detectScoreAnomaly(points)
|
||||
expect(result.isAnomaly).toBe(true)
|
||||
// 历史平均 49,最新 38,绝对下降 11(warning),相对下降 22%(critical)
|
||||
expect(result.severity).toBe("critical")
|
||||
})
|
||||
|
||||
it("历史平均为 0 时不触发异常", () => {
|
||||
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
|
||||
{ normalizedScore: 0 },
|
||||
{ normalizedScore: 0 },
|
||||
{ normalizedScore: 50 },
|
||||
]
|
||||
const result = detectScoreAnomaly(points)
|
||||
expect(result.isAnomaly).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeSignificance (P3-5)", () => {
|
||||
it("样本不足时返回 null", () => {
|
||||
expect(computeSignificance([80], [90])).toBeNull()
|
||||
expect(computeSignificance([], [90, 85])).toBeNull()
|
||||
})
|
||||
|
||||
it("两组相同数据返回 negligible + 不显著", () => {
|
||||
const result = computeSignificance([80, 80, 80], [80, 80, 80])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.effectSizeLabel).toBe("negligible")
|
||||
expect(result!.isSignificant).toBe(false)
|
||||
expect(result!.cohensD).toBe(0)
|
||||
})
|
||||
|
||||
it("显著差异的大样本返回 isSignificant=true", () => {
|
||||
// 两个差异很大的大样本
|
||||
const classA = Array.from({ length: 30 }, (_, i) => 90 - i * 0.5) // 90→75.5
|
||||
const classB = Array.from({ length: 30 }, (_, i) => 60 + i * 0.5) // 60→74.5
|
||||
const result = computeSignificance(classA, classB)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.cohensD).toBeGreaterThan(0.8) // large effect
|
||||
expect(result!.effectSizeLabel).toBe("large")
|
||||
expect(result!.isSignificant).toBe(true)
|
||||
})
|
||||
|
||||
it("微小差异返回 negligible", () => {
|
||||
const result = computeSignificance([80, 81, 79, 80], [80, 79, 81, 80])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.effectSizeLabel).toBe("negligible")
|
||||
})
|
||||
|
||||
it("p-value 范围在 [0, 1]", () => {
|
||||
const result = computeSignificance([80, 85, 90], [75, 70, 65])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.pValue).toBeGreaterThanOrEqual(0)
|
||||
expect(result!.pValue).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findBucketIndex (P3-9)", () => {
|
||||
const buckets = [
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "<60", min: 0, max: 59, count: 0 },
|
||||
]
|
||||
|
||||
it("分数 95 应在 90-100 桶", () => {
|
||||
expect(findBucketIndex(buckets, 95)).toBe(0)
|
||||
})
|
||||
|
||||
it("分数 85 应在 80-89 桶", () => {
|
||||
expect(findBucketIndex(buckets, 85)).toBe(1)
|
||||
})
|
||||
|
||||
it("分数 75 应在 70-79 桶", () => {
|
||||
expect(findBucketIndex(buckets, 75)).toBe(2)
|
||||
})
|
||||
|
||||
it("分数 65 应在 60-69 桶", () => {
|
||||
expect(findBucketIndex(buckets, 65)).toBe(3)
|
||||
})
|
||||
|
||||
it("分数 50 应在 <60 桶", () => {
|
||||
expect(findBucketIndex(buckets, 50)).toBe(4)
|
||||
})
|
||||
|
||||
it("边界分数 90 应在 90-100 桶", () => {
|
||||
expect(findBucketIndex(buckets, 90)).toBe(0)
|
||||
})
|
||||
|
||||
it("边界分数 80 应在 80-89 桶(不进位到 90-100)", () => {
|
||||
expect(findBucketIndex(buckets, 80)).toBe(1)
|
||||
})
|
||||
|
||||
it("分数四舍五入后归入对应桶", () => {
|
||||
expect(findBucketIndex(buckets, 89.6)).toBe(0)
|
||||
})
|
||||
|
||||
it("分数 0 应在 <60 桶", () => {
|
||||
expect(findBucketIndex(buckets, 0)).toBe(4)
|
||||
})
|
||||
|
||||
it("超出范围的负数应返回 -1", () => {
|
||||
expect(findBucketIndex(buckets, -10)).toBe(-1)
|
||||
})
|
||||
|
||||
it("超出 100 的分数应返回 -1", () => {
|
||||
expect(findBucketIndex(buckets, 110)).toBe(-1)
|
||||
})
|
||||
|
||||
it("空桶列表应返回 -1", () => {
|
||||
expect(findBucketIndex([], 50)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildGrowthArchivePoints (P3-4)", () => {
|
||||
const baseRow = {
|
||||
academicYearId: "year-2024",
|
||||
academicYearName: "2024-2025",
|
||||
academicYearStart: new Date("2024-09-01"),
|
||||
subjectId: "subject-math",
|
||||
}
|
||||
|
||||
it("空数组应返回空数组", () => {
|
||||
expect(buildGrowthArchivePoints([])).toEqual([])
|
||||
})
|
||||
|
||||
it("academicYearId 为 null 的行应被跳过", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{
|
||||
...baseRow,
|
||||
academicYearId: null,
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "Midterm",
|
||||
},
|
||||
]
|
||||
expect(buildGrowthArchivePoints(rows)).toEqual([])
|
||||
})
|
||||
|
||||
it("单学年单学期应返回单个数据点", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "Midterm",
|
||||
},
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: 90,
|
||||
fullScore: 100,
|
||||
title: "Final",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0].academicYearId).toBe("year-2024")
|
||||
expect(points[0].semester).toBe("1")
|
||||
expect(points[0].averageScore).toBe(85)
|
||||
expect(points[0].recordCount).toBe(2)
|
||||
expect(points[0].subjectCount).toBe(1)
|
||||
expect(points[0].assessmentCount).toBe(2)
|
||||
})
|
||||
|
||||
it("多学年多学期应按 (year start, semester) 升序排列", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
// 2025 学年 第一学期(应排在最后)
|
||||
{
|
||||
...baseRow,
|
||||
academicYearId: "year-2025",
|
||||
academicYearName: "2025-2026",
|
||||
academicYearStart: new Date("2025-09-01"),
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "Midterm",
|
||||
},
|
||||
// 2024 学年 第二学期(应排第二)
|
||||
{
|
||||
...baseRow,
|
||||
semester: "2",
|
||||
score: 85,
|
||||
fullScore: 100,
|
||||
title: "Final",
|
||||
},
|
||||
// 2024 学年 第一学期(应排第一)
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: 75,
|
||||
fullScore: 100,
|
||||
title: "Midterm",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points).toHaveLength(3)
|
||||
expect(points[0].academicYearId).toBe("year-2024")
|
||||
expect(points[0].semester).toBe("1")
|
||||
expect(points[1].academicYearId).toBe("year-2024")
|
||||
expect(points[1].semester).toBe("2")
|
||||
expect(points[2].academicYearId).toBe("year-2025")
|
||||
expect(points[2].semester).toBe("1")
|
||||
})
|
||||
|
||||
it("应正确计算及格率和优秀率(百分比 0-100)", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
// 60 → 及格
|
||||
{ ...baseRow, semester: "1", score: 60, fullScore: 100, title: "T1" },
|
||||
// 90 → 及格 + 优秀
|
||||
{ ...baseRow, semester: "1", score: 90, fullScore: 100, title: "T2" },
|
||||
// 50 → 不及格
|
||||
{ ...baseRow, semester: "1", score: 50, fullScore: 100, title: "T3" },
|
||||
// 86 → 及格 + 优秀
|
||||
{ ...baseRow, semester: "1", score: 86, fullScore: 100, title: "T4" },
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points).toHaveLength(1)
|
||||
// 3/4 = 75% 及格
|
||||
expect(points[0].passRate).toBe(75)
|
||||
// 2/4 = 50% 优秀
|
||||
expect(points[0].excellentRate).toBe(50)
|
||||
})
|
||||
|
||||
it("应正确统计科目数和考试数", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{
|
||||
...baseRow,
|
||||
subjectId: "math",
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "Midterm",
|
||||
},
|
||||
{
|
||||
...baseRow,
|
||||
subjectId: "english",
|
||||
semester: "1",
|
||||
score: 85,
|
||||
fullScore: 100,
|
||||
title: "Midterm",
|
||||
},
|
||||
{
|
||||
...baseRow,
|
||||
subjectId: "math",
|
||||
semester: "1",
|
||||
score: 90,
|
||||
fullScore: 100,
|
||||
title: "Final",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points).toHaveLength(1)
|
||||
// 2 个科目(math、english)
|
||||
expect(points[0].subjectCount).toBe(2)
|
||||
// 2 个不同的考试(Midterm、Final)
|
||||
expect(points[0].assessmentCount).toBe(2)
|
||||
})
|
||||
|
||||
it("字符串类型分数应正确转换", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: "80",
|
||||
fullScore: "100",
|
||||
title: "Midterm",
|
||||
},
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: "90",
|
||||
fullScore: "100",
|
||||
title: "Final",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0].averageScore).toBe(85)
|
||||
expect(points[0].recordCount).toBe(2)
|
||||
})
|
||||
|
||||
it("归一化分数应正确处理非 100 分制", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
// 80/100 = 80
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "T1",
|
||||
},
|
||||
// 40/50 = 80
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: 40,
|
||||
fullScore: 50,
|
||||
title: "T2",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points[0].averageScore).toBe(80)
|
||||
})
|
||||
|
||||
it("不同学年同学期应分组成不同点", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{
|
||||
...baseRow,
|
||||
academicYearId: "year-2024",
|
||||
academicYearName: "2024-2025",
|
||||
academicYearStart: new Date("2024-09-01"),
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "T1",
|
||||
},
|
||||
{
|
||||
...baseRow,
|
||||
academicYearId: "year-2025",
|
||||
academicYearName: "2025-2026",
|
||||
academicYearStart: new Date("2025-09-01"),
|
||||
semester: "1",
|
||||
score: 90,
|
||||
fullScore: 100,
|
||||
title: "T1",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[0].academicYearId).toBe("year-2024")
|
||||
expect(points[0].averageScore).toBe(80)
|
||||
expect(points[1].academicYearId).toBe("year-2025")
|
||||
expect(points[1].averageScore).toBe(90)
|
||||
})
|
||||
|
||||
it("中位数应正确计算(偶数个数据点)", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{ ...baseRow, semester: "1", score: 70, fullScore: 100, title: "T1" },
|
||||
{ ...baseRow, semester: "1", score: 80, fullScore: 100, title: "T2" },
|
||||
{ ...baseRow, semester: "1", score: 90, fullScore: 100, title: "T3" },
|
||||
{ ...baseRow, semester: "1", score: 100, fullScore: 100, title: "T4" },
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
// 排序后 [70, 80, 90, 100],中位数 = (80 + 90) / 2 = 85
|
||||
expect(points[0].medianScore).toBe(85)
|
||||
})
|
||||
|
||||
it("academicYearStart 应转换为 ISO 字符串", () => {
|
||||
const rows: GrowthArchiveRawRow[] = [
|
||||
{
|
||||
...baseRow,
|
||||
semester: "1",
|
||||
score: 80,
|
||||
fullScore: 100,
|
||||
title: "T1",
|
||||
},
|
||||
]
|
||||
const points = buildGrowthArchivePoints(rows)
|
||||
expect(typeof points[0].academicYearStart).toBe("string")
|
||||
expect(new Date(points[0].academicYearStart).getTime()).toBe(
|
||||
new Date("2024-09-01").getTime()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
GradeDistributionBucket,
|
||||
GradeDistributionResult,
|
||||
GradeStats,
|
||||
GradeRecordSemester,
|
||||
GradeTrendPoint,
|
||||
RankingTrendPoint,
|
||||
StudentGrowthArchivePoint,
|
||||
} from "./types"
|
||||
|
||||
/** Round to 2 decimal places. */
|
||||
@@ -243,6 +245,27 @@ function createDefaultBuckets(): GradeDistributionBucket[] {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-9: 给定归一化分数(0-100),返回其在分布桶中的索引。
|
||||
* 返回 -1 表示分数不在任何桶内(例如分数 < 0 或 > 100)。
|
||||
*
|
||||
* 纯函数 — 无副作用,无 I/O。可独立测试。
|
||||
*
|
||||
* @param buckets - 已计算的分布桶列表
|
||||
* @param normalizedScore - 学生的归一化分数(0-100)
|
||||
*/
|
||||
export function findBucketIndex(
|
||||
buckets: ReadonlyArray<GradeDistributionBucket>,
|
||||
normalizedScore: number
|
||||
): number {
|
||||
const rounded = Math.round(normalizedScore)
|
||||
for (let i = 0; i < buckets.length; i += 1) {
|
||||
const b = buckets[i]
|
||||
if (rounded >= b.min && rounded <= b.max) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucketize raw score rows into a grade distribution result.
|
||||
*/
|
||||
@@ -310,3 +333,363 @@ export function buildRankingTrendPoints(
|
||||
)
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-2: Score anomaly detection result.
|
||||
* Returned when a student's latest score shows a significant drop.
|
||||
*/
|
||||
export interface ScoreAnomalyResult {
|
||||
/** Whether an anomaly was detected. */
|
||||
readonly isAnomaly: boolean
|
||||
/** The latest normalized score (0-100). */
|
||||
readonly latestScore: number
|
||||
/** The historical average normalized score (0-100). */
|
||||
readonly historicalAverage: number
|
||||
/** Absolute drop (historicalAverage - latestScore), positive = drop. */
|
||||
readonly drop: number
|
||||
/** Relative drop percentage (0-1), e.g. 0.15 = 15% drop. */
|
||||
readonly dropRatio: number
|
||||
/** Severity: "warning" (drop ≥ 10 points or 10%), "critical" (drop ≥ 20 points or 20%). */
|
||||
readonly severity: "warning" | "critical" | null
|
||||
}
|
||||
|
||||
/** Thresholds for anomaly detection (aligned with industry practice: 智学网). */
|
||||
const ANOMALY_WARNING_DROP = 10
|
||||
const ANOMALY_CRITICAL_DROP = 20
|
||||
const ANOMALY_WARNING_RATIO = 0.1
|
||||
const ANOMALY_CRITICAL_RATIO = 0.2
|
||||
/** Minimum historical data points required for anomaly detection. */
|
||||
const MIN_HISTORY_POINTS = 2
|
||||
|
||||
/**
|
||||
* P3-2: Detect score anomaly for a student.
|
||||
*
|
||||
* Compares the latest normalized score against the historical average.
|
||||
* Triggers when:
|
||||
* - At least 2 historical data points exist
|
||||
* - Absolute drop ≥ 10 points (warning) or ≥ 20 points (critical)
|
||||
* - OR relative drop ≥ 10% (warning) or ≥ 20% (critical)
|
||||
*
|
||||
* Uses the more severe of absolute/relative thresholds.
|
||||
* Pure function — no side effects, no I/O. Independently testable.
|
||||
*
|
||||
* @param trendPoints - Sorted by date ascending (oldest first). Must be normalized 0-100.
|
||||
* @returns ScoreAnomalyResult with isAnomaly=false if insufficient data.
|
||||
*/
|
||||
export function detectScoreAnomaly(
|
||||
trendPoints: ReadonlyArray<Pick<GradeTrendPoint, "normalizedScore">>
|
||||
): ScoreAnomalyResult {
|
||||
if (trendPoints.length < MIN_HISTORY_POINTS + 1) {
|
||||
return {
|
||||
isAnomaly: false,
|
||||
latestScore: 0,
|
||||
historicalAverage: 0,
|
||||
drop: 0,
|
||||
dropRatio: 0,
|
||||
severity: null,
|
||||
}
|
||||
}
|
||||
|
||||
const history = trendPoints.slice(0, -1)
|
||||
const latest = trendPoints[trendPoints.length - 1]
|
||||
const latestScore = latest.normalizedScore
|
||||
const historicalAverage =
|
||||
history.reduce((sum, p) => sum + p.normalizedScore, 0) / history.length
|
||||
|
||||
if (historicalAverage <= 0) {
|
||||
return {
|
||||
isAnomaly: false,
|
||||
latestScore,
|
||||
historicalAverage: 0,
|
||||
drop: 0,
|
||||
dropRatio: 0,
|
||||
severity: null,
|
||||
}
|
||||
}
|
||||
|
||||
const drop = round2(historicalAverage - latestScore)
|
||||
const dropRatio = drop / historicalAverage
|
||||
|
||||
if (drop <= 0) {
|
||||
return {
|
||||
isAnomaly: false,
|
||||
latestScore,
|
||||
historicalAverage: round2(historicalAverage),
|
||||
drop: 0,
|
||||
dropRatio: 0,
|
||||
severity: null,
|
||||
}
|
||||
}
|
||||
|
||||
const isCriticalAbs = drop >= ANOMALY_CRITICAL_DROP
|
||||
const isCriticalRatio = dropRatio >= ANOMALY_CRITICAL_RATIO
|
||||
const isWarningAbs = drop >= ANOMALY_WARNING_DROP
|
||||
const isWarningRatio = dropRatio >= ANOMALY_WARNING_RATIO
|
||||
|
||||
const severity: ScoreAnomalyResult["severity"] = isCriticalAbs || isCriticalRatio
|
||||
? "critical"
|
||||
: isWarningAbs || isWarningRatio
|
||||
? "warning"
|
||||
: null
|
||||
|
||||
return {
|
||||
isAnomaly: severity !== null,
|
||||
latestScore,
|
||||
historicalAverage: round2(historicalAverage),
|
||||
drop,
|
||||
dropRatio: round2(dropRatio),
|
||||
severity,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-5: Statistical significance result for class comparison.
|
||||
* Computes Cohen's d (effect size) and an approximate p-value using
|
||||
* a two-sample t-test (Welch's approximation). No external dependencies.
|
||||
*/
|
||||
export interface SignificanceResult {
|
||||
/** Cohen's d effect size: <0.2 negligible, 0.2 small, 0.5 medium, 0.8 large. */
|
||||
readonly cohensD: number
|
||||
/** Effect size label: "negligible" | "small" | "medium" | "large". */
|
||||
readonly effectSizeLabel: "negligible" | "small" | "medium" | "large"
|
||||
/** Approximate p-value (two-tailed). */
|
||||
readonly pValue: number
|
||||
/** Whether the difference is statistically significant (p < 0.05). */
|
||||
readonly isSignificant: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-5: Compute statistical significance between two class score distributions.
|
||||
*
|
||||
* Uses Welch's t-test (does not assume equal variances).
|
||||
* Computes Cohen's d for effect size.
|
||||
*
|
||||
* Pure function — no side effects, no I/O. Independently testable.
|
||||
*
|
||||
* @param classA - Scores from class A (normalized 0-100)
|
||||
* @param classB - Scores from class B (normalized 0-100)
|
||||
* @returns SignificanceResult, or null if either group has < 2 samples.
|
||||
*/
|
||||
export function computeSignificance(
|
||||
classA: readonly number[],
|
||||
classB: readonly number[]
|
||||
): SignificanceResult | null {
|
||||
const nA = classA.length
|
||||
const nB = classB.length
|
||||
if (nA < 2 || nB < 2) return null
|
||||
|
||||
const mean = (arr: readonly number[]): number =>
|
||||
arr.reduce((s, v) => s + v, 0) / arr.length
|
||||
const variance = (arr: readonly number[], m: number): number => {
|
||||
const sumSq = arr.reduce((s, v) => s + (v - m) ** 2, 0)
|
||||
return sumSq / (arr.length - 1)
|
||||
}
|
||||
|
||||
const meanA = mean(classA)
|
||||
const meanB = mean(classB)
|
||||
const varA = variance(classA, meanA)
|
||||
const varB = variance(classB, meanB)
|
||||
|
||||
// Pooled standard deviation for Cohen's d
|
||||
const pooledStd = Math.sqrt(((nA - 1) * varA + (nB - 1) * varB) / (nA + nB - 2))
|
||||
if (pooledStd === 0) {
|
||||
return {
|
||||
cohensD: 0,
|
||||
effectSizeLabel: "negligible",
|
||||
pValue: 1,
|
||||
isSignificant: false,
|
||||
}
|
||||
}
|
||||
|
||||
const cohensD = Math.abs(meanA - meanB) / pooledStd
|
||||
|
||||
// Welch's t-statistic
|
||||
const seDiff = Math.sqrt(varA / nA + varB / nB)
|
||||
if (seDiff === 0) {
|
||||
return {
|
||||
cohensD: round2(cohensD),
|
||||
effectSizeLabel: labelEffectSize(cohensD),
|
||||
pValue: 1,
|
||||
isSignificant: false,
|
||||
}
|
||||
}
|
||||
const t = Math.abs(meanA - meanB) / seDiff
|
||||
|
||||
// Welch–Satterthwaite degrees of freedom
|
||||
const num = (varA / nA + varB / nB) ** 2
|
||||
const den = (varA / nA) ** 2 / (nA - 1) + (varB / nB) ** 2 / (nB - 1)
|
||||
const df = num / den
|
||||
|
||||
// Approximate p-value using normal distribution for large df,
|
||||
// or a simplified t-distribution for small df.
|
||||
const pValue = approximatePValue(t, df)
|
||||
|
||||
return {
|
||||
cohensD: round2(cohensD),
|
||||
effectSizeLabel: labelEffectSize(cohensD),
|
||||
pValue: round2(pValue),
|
||||
isSignificant: pValue < 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
/** Label effect size based on Cohen's d. */
|
||||
function labelEffectSize(d: number): SignificanceResult["effectSizeLabel"] {
|
||||
const abs = Math.abs(d)
|
||||
if (abs < 0.2) return "negligible"
|
||||
if (abs < 0.5) return "small"
|
||||
if (abs < 0.8) return "medium"
|
||||
return "large"
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate two-tailed p-value from t-statistic and degrees of freedom.
|
||||
* Uses the normal distribution approximation for df > 30, and a
|
||||
* simplified series expansion for smaller df.
|
||||
*/
|
||||
function approximatePValue(t: number, df: number): number {
|
||||
// For large df, t-distribution converges to normal distribution
|
||||
if (df > 30) {
|
||||
// Standard normal CDF approximation (Abramowitz & Stegun 26.2.17)
|
||||
const z = Math.abs(t)
|
||||
const cdf = 1 - 0.5 * (1 + erf(z / Math.SQRT2))
|
||||
return 2 * cdf
|
||||
}
|
||||
// For small df, use a simplified approximation
|
||||
// p ≈ 2 * (1 - CDF_t(t, df))
|
||||
// Simplified: p ≈ 2 / (1 + t^2/df)^(df/2) for large t
|
||||
const x = df / (df + t * t)
|
||||
// Refined approximation using incomplete beta function ratio
|
||||
// This is a rough but safe upper bound
|
||||
const approx = Math.pow(x, df / 2)
|
||||
return Math.min(1, Math.max(0, 2 * approx * 0.5))
|
||||
}
|
||||
|
||||
/** Error function approximation (Abramowitz & Stegun 7.1.26). */
|
||||
function erf(x: number): number {
|
||||
const sign = x >= 0 ? 1 : -1
|
||||
const ax = Math.abs(x)
|
||||
const a1 = 0.254829592
|
||||
const a2 = -0.284496736
|
||||
const a3 = 1.421413741
|
||||
const a4 = -1.453152027
|
||||
const a5 = 1.061405429
|
||||
const p = 0.3275911
|
||||
const t = 1 / (1 + p * ax)
|
||||
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-ax * ax)
|
||||
return sign * y
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-4: 原始数据行,用于构建学生纵向成长档案。
|
||||
*/
|
||||
export interface GrowthArchiveRawRow {
|
||||
academicYearId: string | null
|
||||
academicYearName: string
|
||||
academicYearStart: Date
|
||||
semester: GradeRecordSemester
|
||||
score: unknown
|
||||
fullScore: unknown
|
||||
subjectId: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-4: 按学年 + 学期分组聚合学生成绩,构建纵向成长档案点序列。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 按 (academicYearId, semester) 分组
|
||||
* 2. 对每组计算归一化平均分、中位数、及格率、优秀率、记录数、科目数、考试数
|
||||
* 3. 按 (academicYearStart, semester) 升序排序(先按学年开始时间,再按学期 1 → 2)
|
||||
*
|
||||
* 跳过 academicYearId 为空的记录(无法归类到学年)。
|
||||
*
|
||||
* 纯函数 — 无副作用,无 I/O。可独立测试。
|
||||
*
|
||||
* @param rows - 学生的全部成绩记录行(含学年信息)
|
||||
* @returns 按 (学年 start, 学期) 升序排列的成长点列表
|
||||
*/
|
||||
export function buildGrowthArchivePoints(
|
||||
rows: ReadonlyArray<GrowthArchiveRawRow>
|
||||
): StudentGrowthArchivePoint[] {
|
||||
if (rows.length === 0) return []
|
||||
|
||||
// 按 (academicYearId, semester) 分组
|
||||
const groups = new Map<string, {
|
||||
academicYearId: string
|
||||
academicYearName: string
|
||||
academicYearStart: Date
|
||||
semester: GradeRecordSemester
|
||||
scores: number[]
|
||||
subjectIds: Set<string>
|
||||
assessmentTitles: Set<string>
|
||||
}>()
|
||||
|
||||
for (const r of rows) {
|
||||
if (!r.academicYearId) continue
|
||||
const key = `${r.academicYearId}__${r.semester}`
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
const score = toNumber(r.score)
|
||||
const fullScore = toNumber(r.fullScore)
|
||||
group.scores.push(normalize(score, fullScore))
|
||||
group.subjectIds.add(r.subjectId)
|
||||
group.assessmentTitles.add(r.title)
|
||||
} else {
|
||||
const score = toNumber(r.score)
|
||||
const fullScore = toNumber(r.fullScore)
|
||||
groups.set(key, {
|
||||
academicYearId: r.academicYearId,
|
||||
academicYearName: r.academicYearName,
|
||||
academicYearStart: r.academicYearStart,
|
||||
semester: r.semester,
|
||||
scores: [normalize(score, fullScore)],
|
||||
subjectIds: new Set<string>([r.subjectId]),
|
||||
assessmentTitles: new Set<string>([r.title]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const points: StudentGrowthArchivePoint[] = []
|
||||
for (const group of groups.values()) {
|
||||
const scores = group.scores
|
||||
if (scores.length === 0) continue
|
||||
|
||||
const sorted = [...scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid]
|
||||
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length
|
||||
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
for (const s of scores) {
|
||||
if (s >= PASS_THRESHOLD) passCount += 1
|
||||
if (s >= EXCELLENT_THRESHOLD) excellentCount += 1
|
||||
}
|
||||
|
||||
points.push({
|
||||
academicYearId: group.academicYearId,
|
||||
academicYearName: group.academicYearName,
|
||||
semester: group.semester,
|
||||
averageScore: round2(avg),
|
||||
medianScore: round2(median),
|
||||
passRate: round2((passCount / scores.length) * 100),
|
||||
excellentRate: round2((excellentCount / scores.length) * 100),
|
||||
recordCount: scores.length,
|
||||
subjectCount: group.subjectIds.size,
|
||||
assessmentCount: group.assessmentTitles.size,
|
||||
academicYearStart: group.academicYearStart.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// 按学年开始时间升序,再按学期("1" → "2")升序
|
||||
points.sort((a, b) => {
|
||||
const timeA = new Date(a.academicYearStart).getTime()
|
||||
const timeB = new Date(b.academicYearStart).getTime()
|
||||
if (timeA !== timeB) return timeA - timeB
|
||||
return a.semester.localeCompare(b.semester)
|
||||
})
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
@@ -163,6 +163,33 @@ export interface ClassComparisonItem {
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-5: 班级对比的统计显著性结果。
|
||||
* 由 data-access 层使用 stats-service.computeSignificance 计算后注入到 UI。
|
||||
*/
|
||||
export interface ClassComparisonSignificance {
|
||||
/** 最高分班级 ID */
|
||||
topClassId: string
|
||||
/** 最低分班级 ID */
|
||||
bottomClassId: string
|
||||
/** Cohen's d 效应量 */
|
||||
cohensD: number
|
||||
/** 效应量标签 */
|
||||
effectSizeLabel: "negligible" | "small" | "medium" | "large"
|
||||
/** p 值(双侧) */
|
||||
pValue: number
|
||||
/** 是否统计显著(p < 0.05) */
|
||||
isSignificant: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-5: 班级对比结果(聚合 items + 显著性分析)。
|
||||
*/
|
||||
export interface ClassComparisonResult {
|
||||
items: ClassComparisonItem[]
|
||||
significance: ClassComparisonSignificance | null
|
||||
}
|
||||
|
||||
export interface SubjectComparisonItem {
|
||||
subjectId: string
|
||||
subjectName: string
|
||||
@@ -194,6 +221,78 @@ export interface GradeDistributionResult {
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-9: 班级分布 + 学生位置(隐私保护视图)。
|
||||
* 用于学生视角查看班级分布时标注自己所在分数段。
|
||||
*/
|
||||
export interface GradeDistributionWithPosition {
|
||||
/** 班级分布(不含学生身份) */
|
||||
distribution: GradeDistributionResult
|
||||
/** 班级 ID */
|
||||
classId: string
|
||||
/** 班级名称(用于展示) */
|
||||
className: string
|
||||
/** 当前学生的最近一次归一化分数(0-100) */
|
||||
studentNormalizedScore: number | null
|
||||
/** 学生所在桶的索引(-1 表示无有效分数) */
|
||||
studentBucketIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-4: 学生纵向成长档案的单个数据点。
|
||||
*
|
||||
* 每个点代表一个学年 + 学期的聚合统计。
|
||||
* 跨学年/学期的连续点串联成长轨迹,便于长期趋势分析。
|
||||
*/
|
||||
export interface StudentGrowthArchivePoint {
|
||||
/** 学年 ID */
|
||||
academicYearId: string
|
||||
/** 学年名称(如 "2024-2025") */
|
||||
academicYearName: string
|
||||
/** 学期("1" 第一学期 / "2" 第二学期) */
|
||||
semester: GradeRecordSemester
|
||||
/** 该学期归一化平均分(0-100) */
|
||||
averageScore: number
|
||||
/** 该学期归一化中位数 */
|
||||
medianScore: number
|
||||
/** 该学期及格率(0-100) */
|
||||
passRate: number
|
||||
/** 该学期优秀率(0-100) */
|
||||
excellentRate: number
|
||||
/** 该学期成绩记录数 */
|
||||
recordCount: number
|
||||
/** 该学期涉及科目数 */
|
||||
subjectCount: number
|
||||
/** 该学期参与的不同考试/作业数 */
|
||||
assessmentCount: number
|
||||
/** 学年开始时间(ISO 字符串,用于按时间排序) */
|
||||
academicYearStart: string
|
||||
}
|
||||
|
||||
/**
|
||||
* P3-4: 学生纵向成长档案结果。
|
||||
*
|
||||
* 跨学年/学期的聚合统计,用于绘制长期成长趋势图。
|
||||
*/
|
||||
export interface StudentGrowthArchiveResult {
|
||||
/** 学生 ID */
|
||||
studentId: string
|
||||
/** 学生姓名 */
|
||||
studentName: string
|
||||
/** 按 (学年 start, 学期) 升序排列的成长点 */
|
||||
points: StudentGrowthArchivePoint[]
|
||||
/** 总览:所有记录的整体平均分 */
|
||||
overallAverage: number
|
||||
/** 总览:所有记录总数 */
|
||||
totalRecords: number
|
||||
/** 总览:所有涉及的科目数 */
|
||||
totalSubjects: number
|
||||
/** 总览:跨越的学年数 */
|
||||
totalAcademicYears: number
|
||||
/** 成长趋势:最新点平均分 - 第一个点平均分(>0 表示提升,<0 表示下降) */
|
||||
growthDelta: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级维度成绩分布(按班级拆分)。
|
||||
* 用于年级主任/教学主任仪表盘的"成绩分布"维度。
|
||||
|
||||
Reference in New Issue
Block a user