feat(grades): add scope-check and update analytics
- Add scope-check lib for grade data access scope validation - Update actions, actions-analytics, data-access, data-access-analytics - Update batch-grade-entry, schema, and types
This commit is contained in:
@@ -8,9 +8,11 @@ import { handleActionError } from "@/shared/lib/action-utils"
|
|||||||
import {
|
import {
|
||||||
getClassComparison,
|
getClassComparison,
|
||||||
getGradeDistribution,
|
getGradeDistribution,
|
||||||
|
getGradeDistributionByGradeId,
|
||||||
getGradeTrend,
|
getGradeTrend,
|
||||||
getSubjectComparison,
|
getSubjectComparison,
|
||||||
type ClassComparisonParams,
|
type ClassComparisonParams,
|
||||||
|
type GradeDistributionByGradeParams,
|
||||||
type GradeDistributionParams,
|
type GradeDistributionParams,
|
||||||
type GradeTrendParams,
|
type GradeTrendParams,
|
||||||
type SubjectComparisonParams,
|
type SubjectComparisonParams,
|
||||||
@@ -18,14 +20,16 @@ import {
|
|||||||
import { getRankingTrend } from "./data-access-ranking"
|
import { getRankingTrend } from "./data-access-ranking"
|
||||||
import {
|
import {
|
||||||
ClassComparisonQuerySchema,
|
ClassComparisonQuerySchema,
|
||||||
|
GradeDistributionByGradeQuerySchema,
|
||||||
GradeDistributionQuerySchema,
|
GradeDistributionQuerySchema,
|
||||||
GradeTrendQuerySchema,
|
GradeTrendQuerySchema,
|
||||||
RankingTrendQuerySchema,
|
RankingTrendQuerySchema,
|
||||||
SubjectComparisonQuerySchema,
|
SubjectComparisonQuerySchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import { assertClassInScope } from "./actions"
|
import { assertClassInScope } from "./lib/scope-check"
|
||||||
import type {
|
import type {
|
||||||
ClassComparisonItem,
|
ClassComparisonItem,
|
||||||
|
GradeDistributionByGradeResult,
|
||||||
GradeDistributionResult,
|
GradeDistributionResult,
|
||||||
GradeTrendResult,
|
GradeTrendResult,
|
||||||
RankingTrendResult,
|
RankingTrendResult,
|
||||||
@@ -191,3 +195,32 @@ export async function getRankingTrendAction(
|
|||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年级仪表盘 - 维度1:获取年级整体 + 按班级拆分的成绩分布。
|
||||||
|
* grade_managed / all / class_taught scope 均可调用,data-access 层已应用行级过滤。
|
||||||
|
*/
|
||||||
|
export async function getGradeDistributionByGradeIdAction(
|
||||||
|
params: Omit<GradeDistributionByGradeParams, "scope">
|
||||||
|
): Promise<ActionState<GradeDistributionByGradeResult>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = GradeDistributionByGradeQuerySchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getGradeDistributionByGradeId({
|
||||||
|
...parsed.data,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
})
|
||||||
|
return { success: true, data: result }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
|
|||||||
import {
|
import {
|
||||||
CreateGradeRecordSchema,
|
CreateGradeRecordSchema,
|
||||||
BatchCreateGradeRecordSchema,
|
BatchCreateGradeRecordSchema,
|
||||||
|
BatchGradeEntryByExamSchema,
|
||||||
UpdateGradeRecordSchema,
|
UpdateGradeRecordSchema,
|
||||||
DeleteGradeRecordSchema,
|
DeleteGradeRecordSchema,
|
||||||
GetGradeRecordByIdSchema,
|
GetGradeRecordByIdSchema,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createGradeRecord,
|
createGradeRecord,
|
||||||
batchCreateGradeRecords,
|
batchCreateGradeRecords,
|
||||||
|
batchCreateGradeRecordsByExam,
|
||||||
undoBatchCreateGradeRecords,
|
undoBatchCreateGradeRecords,
|
||||||
updateGradeRecord,
|
updateGradeRecord,
|
||||||
deleteGradeRecord,
|
deleteGradeRecord,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { PaginatedGradeRecords } from "./data-access"
|
import type { PaginatedGradeRecords } from "./data-access"
|
||||||
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
|
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
|
||||||
|
import { getExamForGradeEntry } from "@/modules/exams/data-access"
|
||||||
import {
|
import {
|
||||||
exportGradeRecordsToExcel,
|
exportGradeRecordsToExcel,
|
||||||
exportClassGradeReportToExcel,
|
exportClassGradeReportToExcel,
|
||||||
@@ -52,37 +55,7 @@ import type {
|
|||||||
ClassRankingItem,
|
ClassRankingItem,
|
||||||
GradeRecord,
|
GradeRecord,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import { assertClassInScope } from "./lib/scope-check"
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that the given classId is within the user's DataScope.
|
|
||||||
* Returns an error message string when access is denied, otherwise null.
|
|
||||||
*
|
|
||||||
* - `all` / `children` / `grade_managed`: allowed (children/grade_managed are
|
|
||||||
* validated at the data layer via row-level filters; classId scoping would
|
|
||||||
* require extra cross-module lookups and is intentionally permissive here).
|
|
||||||
* - `class_taught` / `class_members`: classId must be in scope.classIds.
|
|
||||||
* - `owned`: never allowed (owned scope has no class context).
|
|
||||||
*/
|
|
||||||
export function assertClassInScope(
|
|
||||||
scope: DataScope,
|
|
||||||
classId: string
|
|
||||||
): string | null {
|
|
||||||
if (scope.type === "all") return null
|
|
||||||
if (scope.type === "children") return null
|
|
||||||
if (scope.type === "grade_managed") return null
|
|
||||||
if (scope.type === "class_taught") {
|
|
||||||
return scope.classIds.includes(classId)
|
|
||||||
? null
|
|
||||||
: "You can only access classes you teach"
|
|
||||||
}
|
|
||||||
if (scope.type === "class_members") {
|
|
||||||
return scope.classIds.includes(classId)
|
|
||||||
? null
|
|
||||||
: "You can only access your own class"
|
|
||||||
}
|
|
||||||
return "Access denied for your scope"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v4-P1-6: 成绩录入后通知学生和家长。
|
* v4-P1-6: 成绩录入后通知学生和家长。
|
||||||
@@ -282,6 +255,115 @@ export async function batchCreateGradeRecordsAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按试卷批量录入成绩(每题得分)。
|
||||||
|
*
|
||||||
|
* 必须指定试卷 ID,系统自动从试卷带出科目、标题、满分和题目列表。
|
||||||
|
* 教师只需为每个学生填写每题得分,总分自动计算。
|
||||||
|
*
|
||||||
|
* 写入时同步投影到 exam_submissions + submission_answers,
|
||||||
|
* 使错题集、成绩分析等下游模块无需改造即可读取。
|
||||||
|
*/
|
||||||
|
export async function batchCreateGradeRecordsByExamAction(
|
||||||
|
prevState: ActionState<string[]> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||||
|
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
if (typeof classId !== "string" || classId.length === 0) {
|
||||||
|
return { success: false, message: "缺少班级 ID" }
|
||||||
|
}
|
||||||
|
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
|
||||||
|
return { success: false, message: "缺少成绩数据" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取试卷详情(验证试卷存在 + scope 校验 + 获取 subjectId/fullScore/title)
|
||||||
|
const exam = await getExamForGradeEntry(examId, ctx.dataScope)
|
||||||
|
if (!exam) {
|
||||||
|
return { success: false, message: "试卷不存在或无权访问" }
|
||||||
|
}
|
||||||
|
if (exam.questions.length === 0) {
|
||||||
|
return { success: false, message: "试卷没有题目,无法录入" }
|
||||||
|
}
|
||||||
|
if (!exam.subjectId) {
|
||||||
|
return { success: false, message: "试卷未关联科目" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验班级在 scope 内
|
||||||
|
const scopeError = assertClassInScope(ctx.dataScope, classId)
|
||||||
|
if (scopeError) {
|
||||||
|
return { success: false, message: scopeError }
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
|
||||||
|
|
||||||
|
const parsed = BatchGradeEntryByExamSchema.safeParse({
|
||||||
|
examId,
|
||||||
|
classId,
|
||||||
|
subjectId: exam.subjectId,
|
||||||
|
title: exam.title,
|
||||||
|
fullScore: exam.totalScore,
|
||||||
|
type: formData.get("type") || undefined,
|
||||||
|
semester: formData.get("semester") || undefined,
|
||||||
|
records,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "表单数据无效",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId)
|
||||||
|
|
||||||
|
// 更新诊断掌握度
|
||||||
|
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 {
|
||||||
|
// 掌握度更新失败不阻断成绩录入
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知学生和家长
|
||||||
|
try {
|
||||||
|
await notifyGradeEntered({
|
||||||
|
studentIds: parsed.data.records.map((r) => r.studentId),
|
||||||
|
title: parsed.data.title,
|
||||||
|
subjectId: parsed.data.subjectId,
|
||||||
|
fullScore: parsed.data.fullScore,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 通知失败不阻断成绩录入
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/teacher/grades")
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `录入 ${ids.length} 条成绩记录`,
|
||||||
|
data: ids,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v3-P2-3: 撤销最近一次批量成绩录入。
|
* v3-P2-3: 撤销最近一次批量成绩录入。
|
||||||
* 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。
|
* 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "./stats-service"
|
} from "./stats-service"
|
||||||
import type {
|
import type {
|
||||||
ClassComparisonItem,
|
ClassComparisonItem,
|
||||||
|
GradeDistributionByGradeResult,
|
||||||
GradeDistributionResult,
|
GradeDistributionResult,
|
||||||
GradeTrendResult,
|
GradeTrendResult,
|
||||||
SchoolWideGradeSummary,
|
SchoolWideGradeSummary,
|
||||||
@@ -391,3 +392,90 @@ export const getSchoolWideGradeSummary = cache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年级仪表盘 - 维度1:获取年级整体 + 按班级拆分的成绩分布。
|
||||||
|
* 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询成绩记录,
|
||||||
|
* 应用 buildScopeClassFilter 行级权限过滤,复用 computeGradeDistribution / computeGradeStats 纯函数。
|
||||||
|
*/
|
||||||
|
export interface GradeDistributionByGradeParams {
|
||||||
|
gradeId: string
|
||||||
|
subjectId?: string
|
||||||
|
examId?: string
|
||||||
|
semester?: "1" | "2"
|
||||||
|
scope: DataScope
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGradeDistributionByGradeId = cache(
|
||||||
|
async (params: GradeDistributionByGradeParams): Promise<GradeDistributionByGradeResult> => {
|
||||||
|
const classRows = await getClassesByGradeId(params.gradeId)
|
||||||
|
|
||||||
|
if (classRows.length === 0) {
|
||||||
|
return {
|
||||||
|
overall: computeGradeDistribution([]),
|
||||||
|
stats: null,
|
||||||
|
byClass: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scope 过滤:class_taught 仅保留当前教师所教班级
|
||||||
|
const scope = params.scope
|
||||||
|
const scopeClassIdSet =
|
||||||
|
scope.type === "class_taught" ? new Set(scope.classIds) : null
|
||||||
|
const allowedClassRows = scopeClassIdSet
|
||||||
|
? classRows.filter((c) => scopeClassIdSet.has(c.id))
|
||||||
|
: classRows
|
||||||
|
|
||||||
|
if (allowedClassRows.length === 0) {
|
||||||
|
return {
|
||||||
|
overall: computeGradeDistribution([]),
|
||||||
|
stats: null,
|
||||||
|
byClass: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedClassIds = allowedClassRows.map((c) => c.id)
|
||||||
|
|
||||||
|
const conditions = [inArray(gradeRecords.classId, allowedClassIds)]
|
||||||
|
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||||
|
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||||
|
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||||
|
|
||||||
|
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||||
|
if (scopeFilter) conditions.push(scopeFilter)
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
classId: gradeRecords.classId,
|
||||||
|
score: gradeRecords.score,
|
||||||
|
fullScore: gradeRecords.fullScore,
|
||||||
|
})
|
||||||
|
.from(gradeRecords)
|
||||||
|
.where(and(...conditions))
|
||||||
|
|
||||||
|
const overall = computeGradeDistribution(rows)
|
||||||
|
const stats = computeGradeStats(rows)
|
||||||
|
|
||||||
|
const byClassMap = new Map<string, typeof rows>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const existing = byClassMap.get(r.classId)
|
||||||
|
if (existing) {
|
||||||
|
existing.push(r)
|
||||||
|
} else {
|
||||||
|
byClassMap.set(r.classId, [r])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byClass = allowedClassRows.map((cls) => {
|
||||||
|
const classRowsForClass = byClassMap.get(cls.id) ?? []
|
||||||
|
return {
|
||||||
|
classId: cls.id,
|
||||||
|
className: cls.name,
|
||||||
|
distribution: computeGradeDistribution(classRowsForClass),
|
||||||
|
stats: computeGradeStats(classRowsForClass),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { overall, stats, byClass }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createId } from "@paralleldrive/cuid2"
|
|||||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { gradeDrafts, gradeRecords } from "@/shared/db/schema"
|
import { gradeDrafts, gradeRecords, gradeRecordAnswers, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||||
import {
|
import {
|
||||||
getActiveStudentIdsByClassId,
|
getActiveStudentIdsByClassId,
|
||||||
getClassExists,
|
getClassExists,
|
||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
import type {
|
import type {
|
||||||
BatchCreateGradeRecordInput,
|
BatchCreateGradeRecordInput,
|
||||||
|
BatchGradeEntryByExamInput,
|
||||||
CreateGradeRecordInput,
|
CreateGradeRecordInput,
|
||||||
UpdateGradeRecordInput,
|
UpdateGradeRecordInput,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
@@ -196,6 +197,163 @@ export async function batchCreateGradeRecords(
|
|||||||
return rows.map((r) => r.id)
|
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: 批量撤销成绩录入。
|
* v3-P2-3: 批量撤销成绩录入。
|
||||||
* 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。
|
* 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。
|
||||||
|
|||||||
32
src/modules/grades/lib/scope-check.ts
Normal file
32
src/modules/grades/lib/scope-check.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验给定 classId 是否在用户 DataScope 范围内。
|
||||||
|
* 返回错误消息字符串表示拒绝访问;返回 null 表示允许。
|
||||||
|
*
|
||||||
|
* - `all` / `children` / `grade_managed`: 允许(children/grade_managed 在数据层做行级过滤)
|
||||||
|
* - `class_taught` / `class_members`: classId 必须在 scope.classIds 中
|
||||||
|
* - `owned`: 永远拒绝(owned scope 无 class 上下文)
|
||||||
|
*
|
||||||
|
* 注意:本函数为纯同步函数,**不能**放在 "use server" 文件中直接 export,
|
||||||
|
* 否则 Next.js 会将其视为 Server Action 并要求 async。故独立到此文件。
|
||||||
|
*/
|
||||||
|
export function assertClassInScope(
|
||||||
|
scope: DataScope,
|
||||||
|
classId: string,
|
||||||
|
): string | null {
|
||||||
|
if (scope.type === "all") return null
|
||||||
|
if (scope.type === "children") return null
|
||||||
|
if (scope.type === "grade_managed") return null
|
||||||
|
if (scope.type === "class_taught") {
|
||||||
|
return scope.classIds.includes(classId)
|
||||||
|
? null
|
||||||
|
: "You can only access classes you teach"
|
||||||
|
}
|
||||||
|
if (scope.type === "class_members") {
|
||||||
|
return scope.classIds.includes(classId)
|
||||||
|
? null
|
||||||
|
: "You can only access your own class"
|
||||||
|
}
|
||||||
|
return "Access denied for your scope"
|
||||||
|
}
|
||||||
@@ -130,8 +130,41 @@ export const GradeDistributionQuerySchema = z.object({
|
|||||||
semester: GradeRecordSemesterEnum.optional(),
|
semester: GradeRecordSemesterEnum.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const GradeDistributionByGradeQuerySchema = z.object({
|
||||||
|
gradeId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
examId: z.string().optional(),
|
||||||
|
semester: GradeRecordSemesterEnum.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const RankingTrendQuerySchema = z.object({
|
export const RankingTrendQuerySchema = z.object({
|
||||||
studentId: z.string().min(1),
|
studentId: z.string().min(1),
|
||||||
subjectId: z.string().optional(),
|
subjectId: z.string().optional(),
|
||||||
semester: GradeRecordSemesterEnum.optional(),
|
semester: GradeRecordSemesterEnum.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- 按试卷批量录入(每题得分)---
|
||||||
|
|
||||||
|
export const BatchGradeEntryByExamQuestionSchema = z.object({
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
score: z.coerce.number().min(0).max(1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BatchGradeEntryByExamItemSchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
answers: z.array(BatchGradeEntryByExamQuestionSchema).min(1),
|
||||||
|
remark: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BatchGradeEntryByExamSchema = z.object({
|
||||||
|
examId: z.string().min(1),
|
||||||
|
classId: z.string().min(1),
|
||||||
|
subjectId: z.string().min(1),
|
||||||
|
title: z.string().min(1).max(255),
|
||||||
|
fullScore: z.coerce.number().min(1).max(1000),
|
||||||
|
type: GradeRecordTypeEnum.optional(),
|
||||||
|
semester: GradeRecordSemesterEnum.optional(),
|
||||||
|
records: z.array(BatchGradeEntryByExamItemSchema).min(1).max(500),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type BatchGradeEntryByExamInput = z.infer<typeof BatchGradeEntryByExamSchema>
|
||||||
|
|||||||
@@ -194,6 +194,26 @@ export interface GradeDistributionResult {
|
|||||||
totalCount: number
|
totalCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 年级维度成绩分布(按班级拆分)。
|
||||||
|
* 用于年级主任/教学主任仪表盘的"成绩分布"维度。
|
||||||
|
*/
|
||||||
|
export interface GradeDistributionByGradeClassItem {
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
distribution: GradeDistributionResult
|
||||||
|
stats: GradeStats | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradeDistributionByGradeResult {
|
||||||
|
/** 年级整体分布(已应用 scope 过滤) */
|
||||||
|
overall: GradeDistributionResult
|
||||||
|
/** 年级整体统计(均分/及格率/优秀率等) */
|
||||||
|
stats: GradeStats | null
|
||||||
|
/** 按班级拆分的分布 + 统计 */
|
||||||
|
byClass: GradeDistributionByGradeClassItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface RankingTrendPoint {
|
export interface RankingTrendPoint {
|
||||||
/** Title of the exam/assessment */
|
/** Title of the exam/assessment */
|
||||||
title: string
|
title: string
|
||||||
@@ -238,3 +258,30 @@ export interface SelectOption {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 按试卷批量录入(每题得分)---
|
||||||
|
|
||||||
|
/** 成绩每题得分记录(对应 grade_record_answers 表) */
|
||||||
|
export interface GradeRecordAnswer {
|
||||||
|
id: string
|
||||||
|
gradeRecordId: string
|
||||||
|
questionId: string
|
||||||
|
score: number
|
||||||
|
fullScore: number
|
||||||
|
feedback: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个学生的每题得分录入项 */
|
||||||
|
export interface BatchGradeEntryByExamQuestion {
|
||||||
|
questionId: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个学生的录入项 */
|
||||||
|
export interface BatchGradeEntryByExamItem {
|
||||||
|
studentId: string
|
||||||
|
answers: BatchGradeEntryByExamQuestion[]
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user