Files
NextEdu/src/modules/attendance/actions.ts
SpecialX e2e0487a3b feat(attendance,elective): 实现所有 P2 长期改进项
P2 修复(来自审计报告):
- 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action)
- 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面)
- 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页)
- 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid)
- 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页)
- 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重)
- 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入)

P2 建议项:
- 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict)
- 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit)
- 考勤/选课数据导出 Excel(export.ts + API 路由扩展)

新增文件:
- src/modules/attendance/components/attendance-page-layout.tsx
- src/modules/elective/components/elective-page-layout.tsx
- src/modules/elective/resolvers.ts
- src/modules/attendance/export.ts
- src/modules/elective/export.ts

校验:
- npm run lint 通过(exit 0)
- npx tsc --noEmit attendance/elective/parent 相关零错误
2026-06-23 09:02:41 +08:00

259 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { trackEvent } from "@/shared/lib/track-event"
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
import {
RecordAttendanceSchema,
BatchRecordAttendanceSchema,
UpdateAttendanceSchema,
AttendanceRuleSchema,
} from "./schema"
import {
createAttendanceRecord,
batchCreateAttendanceRecords,
updateAttendanceRecord,
deleteAttendanceRecord,
getAttendanceRecordClassId,
upsertAttendanceRules,
} from "./data-access"
/**
* 校验当前用户对考勤记录的归属权限。
* - adminscope=all直接放行
* - teacherscope=class_taught必须为记录所属班级的任课教师
* - 其他 scope拒绝学生/家长不应调用写操作)
*/
async function assertRecordOwnership(
recordId: string,
ctx: Awaited<ReturnType<typeof requirePermission>>
): Promise<{ ok: boolean; message?: string }> {
const t = await getTranslations("attendance")
if (ctx.dataScope.type === "all") return { ok: true }
if (ctx.dataScope.type === "class_taught") {
const classId = await getAttendanceRecordClassId(recordId)
if (!classId) return { ok: false, message: t("errors.notFound") }
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
if (!owns) return { ok: false, message: t("errors.noOwnership") }
return { ok: true }
}
return { ok: false, message: t("errors.insufficientPermissions") }
}
/**
* 校验当前用户对班级的归属权限。
* - adminscope=all直接放行
* - teacherscope=class_taught必须为该班级的任课教师
* - 其他 scope拒绝
*/
async function assertClassOwnership(
classId: string,
ctx: Awaited<ReturnType<typeof requirePermission>>
): Promise<{ ok: boolean; message?: string }> {
const t = await getTranslations("attendance")
if (ctx.dataScope.type === "all") return { ok: true }
if (ctx.dataScope.type === "class_taught") {
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
if (!owns) return { ok: false, message: t("errors.noClassOwnership") }
return { ok: true }
}
return { ok: false, message: t("errors.insufficientPermissions") }
}
export async function recordAttendanceAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("attendance")
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const parsed = RecordAttendanceSchema.safeParse({
studentId: formData.get("studentId"),
classId: formData.get("classId"),
date: formData.get("date"),
status: formData.get("status"),
remark: formData.get("remark") || undefined,
scheduleId: formData.get("scheduleId") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createAttendanceRecord(parsed.data, ctx.userId)
revalidatePath("/teacher/attendance")
await trackEvent({
event: "attendance.recorded",
userId: ctx.userId,
targetId: id,
targetType: "attendance_record",
properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, status: parsed.data.status },
})
return { success: true, message: t("messages.recorded"), data: id }
} catch (e) {
return handleActionError(e)
}
}
export async function batchRecordAttendanceAction(
prevState: ActionState<number> | null,
formData: FormData
): Promise<ActionState<number>> {
try {
const t = await getTranslations("attendance")
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const recordsJson = formData.get("recordsJson")
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: t("errors.missingRecords") }
}
const parsed = BatchRecordAttendanceSchema.safeParse({
records: safeJsonParse(recordsJson, t("errors.invalidRecordsJson")),
})
if (!parsed.success) {
return {
success: false,
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId)
revalidatePath("/teacher/attendance")
await trackEvent({
event: "attendance.batch_recorded",
userId: ctx.userId,
targetType: "attendance_record",
properties: { count, classId: parsed.data.records[0]?.classId },
})
return { success: true, message: t("messages.batchRecorded", { count }), data: count }
} catch (e) {
return handleActionError(e)
}
}
export async function updateAttendanceAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("attendance")
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ownership = await assertRecordOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
const parsed = UpdateAttendanceSchema.safeParse({
status: formData.get("status") || undefined,
remark: formData.get("remark") || undefined,
scheduleId: formData.get("scheduleId") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
await updateAttendanceRecord(id, parsed.data)
revalidatePath("/teacher/attendance")
await trackEvent({
event: "attendance.updated",
userId: ctx.userId,
targetId: id,
targetType: "attendance_record",
properties: { status: parsed.data.status },
})
return { success: true, message: t("messages.updated") }
} catch (e) {
return handleActionError(e)
}
}
export async function deleteAttendanceAction(
id: string
): Promise<ActionState<string>> {
try {
const t = await getTranslations("attendance")
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ownership = await assertRecordOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
await deleteAttendanceRecord(id)
revalidatePath("/teacher/attendance")
await trackEvent({
event: "attendance.deleted",
userId: ctx.userId,
targetId: id,
targetType: "attendance_record",
})
return { success: true, message: t("messages.deleted") }
} catch (e) {
return handleActionError(e)
}
}
export async function saveAttendanceRulesAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("attendance")
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const parsed = AttendanceRuleSchema.safeParse({
classId: formData.get("classId"),
lateThresholdMinutes: formData.get("lateThresholdMinutes") || undefined,
earlyLeaveThresholdMinutes: formData.get("earlyLeaveThresholdMinutes") || undefined,
enableAutoMark: formData.get("enableAutoMark") === "true",
})
if (!parsed.success) {
return {
success: false,
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
const ownership = await assertClassOwnership(parsed.data.classId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
const id = await upsertAttendanceRules(parsed.data)
revalidatePath("/teacher/attendance")
await trackEvent({
event: "attendance.rules_saved",
userId: ctx.userId,
targetId: id,
targetType: "attendance_rule",
properties: { classId: parsed.data.classId },
})
return { success: true, message: t("messages.rulesSaved"), data: id }
} catch (e) {
return handleActionError(e)
}
}