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 {
|
||||
getClassComparison,
|
||||
getGradeDistribution,
|
||||
getGradeDistributionByGradeId,
|
||||
getGradeTrend,
|
||||
getSubjectComparison,
|
||||
type ClassComparisonParams,
|
||||
type GradeDistributionByGradeParams,
|
||||
type GradeDistributionParams,
|
||||
type GradeTrendParams,
|
||||
type SubjectComparisonParams,
|
||||
@@ -18,14 +20,16 @@ import {
|
||||
import { getRankingTrend } from "./data-access-ranking"
|
||||
import {
|
||||
ClassComparisonQuerySchema,
|
||||
GradeDistributionByGradeQuerySchema,
|
||||
GradeDistributionQuerySchema,
|
||||
GradeTrendQuerySchema,
|
||||
RankingTrendQuerySchema,
|
||||
SubjectComparisonQuerySchema,
|
||||
} from "./schema"
|
||||
import { assertClassInScope } from "./actions"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionByGradeResult,
|
||||
GradeDistributionResult,
|
||||
GradeTrendResult,
|
||||
RankingTrendResult,
|
||||
@@ -191,3 +195,32 @@ export async function getRankingTrendAction(
|
||||
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 {
|
||||
CreateGradeRecordSchema,
|
||||
BatchCreateGradeRecordSchema,
|
||||
BatchGradeEntryByExamSchema,
|
||||
UpdateGradeRecordSchema,
|
||||
DeleteGradeRecordSchema,
|
||||
GetGradeRecordByIdSchema,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import {
|
||||
createGradeRecord,
|
||||
batchCreateGradeRecords,
|
||||
batchCreateGradeRecordsByExam,
|
||||
undoBatchCreateGradeRecords,
|
||||
updateGradeRecord,
|
||||
deleteGradeRecord,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
} from "./data-access"
|
||||
import type { PaginatedGradeRecords } from "./data-access"
|
||||
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
|
||||
import { getExamForGradeEntry } from "@/modules/exams/data-access"
|
||||
import {
|
||||
exportGradeRecordsToExcel,
|
||||
exportClassGradeReportToExcel,
|
||||
@@ -52,37 +55,7 @@ import type {
|
||||
ClassRankingItem,
|
||||
GradeRecord,
|
||||
} from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
* Validate that the given classId is within the user's DataScope.
|
||||
* Returns an error message string when access is denied, otherwise null.
|
||||
*
|
||||
* - `all` / `children` / `grade_managed`: allowed (children/grade_managed are
|
||||
* validated at the data layer via row-level filters; classId scoping would
|
||||
* require extra cross-module lookups and is intentionally permissive here).
|
||||
* - `class_taught` / `class_members`: classId must be in scope.classIds.
|
||||
* - `owned`: never allowed (owned scope has no class context).
|
||||
*/
|
||||
export function assertClassInScope(
|
||||
scope: DataScope,
|
||||
classId: string
|
||||
): string | null {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "children") return null
|
||||
if (scope.type === "grade_managed") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.includes(classId)
|
||||
? null
|
||||
: "You can only access classes you teach"
|
||||
}
|
||||
if (scope.type === "class_members") {
|
||||
return scope.classIds.includes(classId)
|
||||
? null
|
||||
: "You can only access your own class"
|
||||
}
|
||||
return "Access denied for your scope"
|
||||
}
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
|
||||
/**
|
||||
* 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: 撤销最近一次批量成绩录入。
|
||||
* 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import {
|
||||
} from "./stats-service"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionByGradeResult,
|
||||
GradeDistributionResult,
|
||||
GradeTrendResult,
|
||||
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 { db } from "@/shared/db"
|
||||
import { gradeDrafts, gradeRecords } from "@/shared/db/schema"
|
||||
import { gradeDrafts, gradeRecords, gradeRecordAnswers, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassExists,
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "./types"
|
||||
import type {
|
||||
BatchCreateGradeRecordInput,
|
||||
BatchGradeEntryByExamInput,
|
||||
CreateGradeRecordInput,
|
||||
UpdateGradeRecordInput,
|
||||
} from "./schema"
|
||||
@@ -196,6 +197,163 @@ 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 列表),且仅允许撤销自己录入的记录。
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
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({
|
||||
studentId: z.string().min(1),
|
||||
subjectId: z.string().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
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级维度成绩分布(按班级拆分)。
|
||||
* 用于年级主任/教学主任仪表盘的"成绩分布"维度。
|
||||
*/
|
||||
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 {
|
||||
/** Title of the exam/assessment */
|
||||
title: string
|
||||
@@ -238,3 +258,30 @@ export interface SelectOption {
|
||||
id: 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