refactor(attendance,elective): 审计第二轮 — 全量完成 P0/P1 改进项

P0 修复:
- 页面层 i18n 全量补齐(admin/teacher/parent/student × attendance/elective)
- types.ts 状态标签常量迁移至 constants.ts(i18n key + Badge variant)
- 修复 getTranslations 导入路径(next-intl → next-intl/server)

P1 改进:
- 解耦 parent 模块对 attendance 类型的直接依赖(本地 view-model 类型)
- 导出纯函数(computeStats/buildWarnings/buildLotteryRankCase 等)
- 统一空状态为 EmptyState 组件
- 清理死代码读 Action(attendance 5 个 + elective 3 个)
- 预留监控埋点接口(trackEvent 13 个新事件名)
- 补齐骨架屏 loading.tsx(8 个页面)
- AlertDialog 替换 window.confirm(student-selection-view)
- a11y 改进(aria-label/role/键盘导航)

修复:
- AttendanceStatus 从 constants.ts 重导出,消除 types/constants 双源混乱
- buildWarnings 的 Translator 类型改用 ReturnType<typeof useTranslations>
This commit is contained in:
SpecialX
2026-06-22 17:33:29 +08:00
parent 76966581b8
commit f62b8c0f86
46 changed files with 1748 additions and 545 deletions

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { trackEvent } from "@/shared/lib/track-event"
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
import {
@@ -18,16 +19,8 @@ import {
updateAttendanceRecord,
deleteAttendanceRecord,
getAttendanceRecordClassId,
getAttendanceRecords,
getClassAttendanceForDate,
getAttendanceRules,
upsertAttendanceRules,
} from "./data-access"
import {
getStudentAttendanceSummary,
getClassAttendanceStats,
} from "./data-access-stats"
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
/**
* 校验当前用户对考勤记录的归属权限。
@@ -50,6 +43,25 @@ async function assertRecordOwnership(
return { ok: false, message: "Insufficient permissions" }
}
/**
* 校验当前用户对班级的归属权限。
* - adminscope=all直接放行
* - teacherscope=class_taught必须为该班级的任课教师
* - 其他 scope拒绝
*/
async function assertClassOwnership(
classId: string,
ctx: Awaited<ReturnType<typeof requirePermission>>
): Promise<{ ok: boolean; message?: string }> {
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: "You do not own this class" }
return { ok: true }
}
return { ok: false, message: "Insufficient permissions" }
}
export async function recordAttendanceAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -76,6 +88,13 @@ export async function recordAttendanceAction(
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: "Attendance recorded", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -110,6 +129,12 @@ export async function batchRecordAttendanceAction(
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: `Recorded attendance for ${count} students`, data: count }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -147,6 +172,13 @@ export async function updateAttendanceAction(
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: "Attendance updated" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -168,89 +200,13 @@ export async function deleteAttendanceAction(
await deleteAttendanceRecord(id)
revalidatePath("/teacher/attendance")
return { success: true, message: "Attendance record deleted" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getAttendanceAction(
params: AttendanceQueryParams
): Promise<ActionState<{ items: AttendanceListItem[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
const result = await getAttendanceRecords({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
await trackEvent({
event: "attendance.deleted",
userId: ctx.userId,
targetId: id,
targetType: "attendance_record",
})
return {
success: true,
data: {
items: result.items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages,
},
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getStudentAttendanceAction(
studentId: string,
startDate?: string,
endDate?: string
): Promise<ActionState<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> {
try {
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own attendance" }
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return { success: false, message: "Can only view your children's attendance" }
}
const summary = await getStudentAttendanceSummary(studentId, startDate, endDate)
return { success: true, data: summary }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getClassAttendanceStatsAction(
classId: string,
startDate?: string,
endDate?: string
): Promise<ActionState<Awaited<ReturnType<typeof getClassAttendanceStats>>>> {
try {
await requirePermission(Permissions.ATTENDANCE_READ)
const result = await getClassAttendanceStats(classId, startDate, endDate)
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getClassAttendanceForDateAction(
classId: string,
date: string
): Promise<ActionState<AttendanceListItem[]>> {
try {
await requirePermission(Permissions.ATTENDANCE_READ)
const records = await getClassAttendanceForDate(classId, date)
return { success: true, data: records }
return { success: true, message: "Attendance record deleted" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
@@ -263,7 +219,7 @@ export async function saveAttendanceRulesAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const parsed = AttendanceRuleSchema.safeParse({
classId: formData.get("classId"),
@@ -280,8 +236,20 @@ export async function saveAttendanceRulesAction(
}
}
const ownership = await assertClassOwnership(parsed.data.classId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
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: "Attendance rules saved", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -289,17 +257,3 @@ export async function saveAttendanceRulesAction(
return { success: false, message: "Unexpected error" }
}
}
export async function getAttendanceRulesAction(
classId?: string
): Promise<ActionState<Awaited<ReturnType<typeof getAttendanceRules>>>> {
try {
await requirePermission(Permissions.ATTENDANCE_READ)
const rules = await getAttendanceRules(classId)
return { success: true, data: rules }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}