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 相关零错误
This commit is contained in:
SpecialX
2026-06-23 09:02:41 +08:00
parent c766951374
commit e2e0487a3b
50 changed files with 1514 additions and 411 deletions

View File

@@ -1,9 +1,11 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
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 { trackEvent } from "@/shared/lib/track-event"
import {
@@ -23,12 +25,6 @@ import {
} from "./data-access"
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
const handleError = (e: unknown): ActionState<never> => {
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" }
}
const revalidateElectivePaths = (id?: string) => {
revalidatePath("/admin/elective")
revalidatePath("/teacher/elective")
@@ -54,11 +50,12 @@ async function assertCourseOwnership(
courseId: string,
ctx: Awaited<ReturnType<typeof requirePermission>>
): Promise<{ ok: boolean; message?: string }> {
const t = await getTranslations("elective")
if (ctx.dataScope.type === "all") return { ok: true }
const course = await getElectiveCourseById(courseId)
if (!course) return { ok: false, message: "Course not found" }
if (!course) return { ok: false, message: t("errors.notFound") }
if (course.teacherId !== ctx.userId) {
return { ok: false, message: "You do not own this course" }
return { ok: false, message: t("errors.noOwnership") }
}
return { ok: true }
}
@@ -68,6 +65,7 @@ export async function createElectiveCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = CreateElectiveCourseSchema.safeParse({
name: formData.get("name"),
@@ -88,7 +86,7 @@ export async function createElectiveCourseAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -101,9 +99,9 @@ export async function createElectiveCourseAction(
targetType: "elective_course",
properties: { capacity: parsed.data.capacity, selectionMode: parsed.data.selectionMode },
})
return { success: true, message: "Elective course created", data: id }
return { success: true, message: t("messages.created"), data: id }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -113,11 +111,12 @@ export async function updateElectiveCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const ownership = await assertCourseOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
const parsed = UpdateElectiveCourseSchema.safeParse({
@@ -140,7 +139,7 @@ export async function updateElectiveCourseAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -152,9 +151,9 @@ export async function updateElectiveCourseAction(
targetId: id,
targetType: "elective_course",
})
return { success: true, message: "Elective course updated", data: id }
return { success: true, message: t("messages.updated"), data: id }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -163,12 +162,13 @@ export async function deleteElectiveCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const id = requireCourseId(formData)
const ownership = await assertCourseOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
await deleteElectiveCourse(id)
@@ -179,9 +179,9 @@ export async function deleteElectiveCourseAction(
targetId: id,
targetType: "elective_course",
})
return { success: true, message: "Elective course deleted" }
return { success: true, message: t("messages.deleted") }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -190,12 +190,13 @@ export async function openSelectionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
const ownership = await assertCourseOwnership(courseId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
await openSelection(courseId)
@@ -206,9 +207,9 @@ export async function openSelectionAction(
targetId: courseId,
targetType: "elective_course",
})
return { success: true, message: "Selection opened" }
return { success: true, message: t("messages.selectionOpened") }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -217,12 +218,13 @@ export async function closeSelectionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
const ownership = await assertCourseOwnership(courseId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
await closeSelection(courseId)
@@ -233,9 +235,9 @@ export async function closeSelectionAction(
targetId: courseId,
targetType: "elective_course",
})
return { success: true, message: "Selection closed" }
return { success: true, message: t("messages.selectionClosed") }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -244,6 +246,7 @@ export async function runLotteryAction(
formData: FormData
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = RunLotterySchema.safeParse({
courseId: formData.get("courseId"),
@@ -251,14 +254,14 @@ export async function runLotteryAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
const ownership = await assertCourseOwnership(parsed.data.courseId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") }
}
const result = await runLottery(parsed.data.courseId)
@@ -272,11 +275,11 @@ export async function runLotteryAction(
})
return {
success: true,
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
message: t("messages.lotteryCompleted", { enrolled: result.enrolled, waitlist: result.waitlist }),
data: result,
}
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -285,6 +288,7 @@ export async function selectCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const parsed = SelectCourseSchema.safeParse({
courseId: formData.get("courseId"),
@@ -293,7 +297,7 @@ export async function selectCourseAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -308,7 +312,7 @@ export async function selectCourseAction(
})
return { success: true, message: result.message, data: result.status }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}
@@ -317,6 +321,7 @@ export async function dropCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const parsed = DropCourseSchema.safeParse({
courseId: formData.get("courseId"),
@@ -324,7 +329,7 @@ export async function dropCourseAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("errors.invalidForm"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -336,8 +341,8 @@ export async function dropCourseAction(
targetId: parsed.data.courseId,
targetType: "course_selection",
})
return { success: true, message: "Course dropped" }
return { success: true, message: t("messages.courseDropped") }
} catch (e) {
return handleError(e)
return handleActionError(e)
}
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react"
import { cn } from "@/shared/lib/utils"
/**
* 选修课模块页面布局(消除 admin/teacher 列表页重复结构)。
*
* 复用模式:标题区 + 内容区(含创建按钮)。
*/
interface ElectivePageLayoutProps {
/** 页面头部(标题 + 描述) */
header: ReactNode
/** 主体内容(课程列表等) */
children: ReactNode
/** 额外类名 */
className?: string
}
export function ElectivePageLayout({
header,
children,
className,
}: ElectivePageLayoutProps) {
return (
<div className={cn("flex h-full flex-col space-y-8 p-8", className)}>
{header}
{children}
</div>
)
}

View File

@@ -11,6 +11,9 @@ import {
import type { CourseSelectionStatus } from "./types"
/** 学分上限K12 选修课学期学分上限,可按需调整) */
const MAX_CREDIT_PER_TERM = 10
/**
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
*/
@@ -21,6 +24,117 @@ export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END`
}
/**
* 解析课程 schedule 字段为可比较的时间段(纯函数,便于测试)。
* schedule 格式约定:"周一 14:00-15:30" 或 "Mon 14:00-15:30"。
* 返回 null 表示无法解析(不参与冲突检测)。
*/
export function parseSchedule(schedule: string | null): { day: string; start: string; end: string } | null {
if (!schedule || schedule.length === 0) return null
// 匹配 "周X HH:MM-HH:MM" 或 "Day HH:MM-HH:MM"
const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+(\d{1,2}:\d{2})\s*[-~]\s*(\d{1,2}:\d{2})/i)
if (!match) return null
const [, day, start, end] = match
return { day: day ?? "", start: start ?? "", end: end ?? "" }
}
/**
* 检测两个时间段是否冲突(纯函数,便于测试)。
* 仅当 day 相同且时间区间重叠时判定为冲突。
*/
export function isScheduleConflict(
a: { day: string; start: string; end: string },
b: { day: string; start: string; end: string }
): boolean {
// 归一化星期表示(周一/Mon → 1周二/Tue → 2 ...
const normalizeDay = (d: string): string => {
const dayMap: Record<string, string> = {
"周一": "1", "周二": "2", "周三": "3", "周四": "4", "周五": "5", "周六": "6", "周日": "7", "周天": "7",
"mon": "1", "tue": "2", "wed": "3", "thu": "4", "fri": "5", "sat": "6", "sun": "7",
}
return dayMap[d.toLowerCase()] ?? d
}
if (normalizeDay(a.day) !== normalizeDay(b.day)) return false
return a.start < b.end && b.start < a.end
}
/**
* 检测学生选课时间冲突P2 建议:选课时间冲突检测)。
* 查询学生已选/已录取的课程,与新课程 schedule 比对。
*/
async function checkScheduleConflict(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
studentId: string,
newCourseId: string
): Promise<boolean> {
const [newCourse] = await tx
.select({ schedule: electiveCourses.schedule })
.from(electiveCourses)
.where(eq(electiveCourses.id, newCourseId))
.limit(1)
const newSchedule = parseSchedule(newCourse?.schedule ?? null)
if (!newSchedule) return false
const existingCourses = await tx
.select({
schedule: electiveCourses.schedule,
})
.from(courseSelections)
.innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
.where(
and(
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
for (const row of existingCourses) {
const existing = parseSchedule(row.schedule)
if (existing && isScheduleConflict(newSchedule, existing)) {
return true
}
}
return false
}
/**
* 检测学生学分是否超限P2 建议:学分上限校验)。
* 查询学生已选课程的学分总和,加上新课程学分后是否超过上限。
*/
async function checkCreditLimit(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
studentId: string,
newCourseId: string
): Promise<{ exceeded: boolean; current: number; max: number }> {
const [newCourse] = await tx
.select({ credit: electiveCourses.credit })
.from(electiveCourses)
.where(eq(electiveCourses.id, newCourseId))
.limit(1)
const newCredit = Number(newCourse?.credit ?? 0)
const existing = await tx
.select({
credit: electiveCourses.credit,
})
.from(courseSelections)
.innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
.where(
and(
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
const currentCredit = existing.reduce((sum, row) => sum + Number(row.credit ?? 0), 0)
const total = currentCredit + newCredit
return {
exceeded: total > MAX_CREDIT_PER_TERM,
current: total,
max: MAX_CREDIT_PER_TERM,
}
}
export async function runLottery(courseId: string): Promise<{
enrolled: number
waitlist: number
@@ -139,6 +253,18 @@ export async function selectCourse(
.limit(1)
if (existing) throw new Error("Already selected this course")
// P2 建议:选课时间冲突检测
const hasConflict = await checkScheduleConflict(tx, studentId, courseId)
if (hasConflict) {
throw new Error("Schedule conflicts with your existing courses")
}
// P2 建议:学分上限校验
const creditCheck = await checkCreditLimit(tx, studentId, courseId)
if (creditCheck.exceeded) {
throw new Error(`Credit limit exceeded (${creditCheck.current}/${creditCheck.max})`)
}
const id = createId()
let status: CourseSelectionStatus = "selected"
let enrolledAt: Date | null = null

View File

@@ -9,15 +9,13 @@ import {
electiveCourses,
} from "@/shared/db/schema"
import { getStudentActiveGradeId } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import {
buildCourseSelect,
mapCourseRow,
resolveCourseDisplayNames,
type CourseCoreRow,
} from "./data-access"
import { getStudentGradeResolver, getCourseDisplayResolver } from "./resolvers"
import type {
CourseSelectionWithDetails,
ElectiveCourseWithDetails,
@@ -92,7 +90,7 @@ const buildSelectionCoreSelect = () =>
const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map<string, string | null>> => {
const studentIds = Array.from(new Set(rows.map((r) => r.studentId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const userMap = await getUserNamesByIds(studentIds)
const userMap = await getCourseDisplayResolver().getUserNamesByIds(studentIds)
const studentNames = new Map<string, string | null>()
for (const [id, user] of userMap.entries()) {
studentNames.set(id, user.name)
@@ -125,7 +123,7 @@ export const getStudentSelections = cache(
)
export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => {
return getStudentActiveGradeId(studentId)
return getStudentGradeResolver().getStudentActiveGradeId(studentId)
})
export const getAvailableCoursesForStudent = cache(

View File

@@ -7,8 +7,7 @@ import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import { electiveCourses } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { safeParseDate } from "@/shared/lib/action-utils"
import type {
ElectiveCourseWithDetails,
@@ -18,6 +17,7 @@ import type {
CreateElectiveCourseInput,
UpdateElectiveCourseInput,
} from "./schema"
import { getCourseDisplayResolver } from "./resolvers"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
@@ -97,16 +97,24 @@ export const buildCourseSelect = () =>
})
.from(electiveCourses)
/**
* 缓存科目/年级选项(单次请求内复用,避免高频访问时重复查询)。
* 使用 React `cache()` 在同一渲染周期内去重。
*/
const getCachedSubjectOptions = cache(async () => getCourseDisplayResolver().getSubjectOptions())
const getCachedGradeOptions = cache(async () => getCourseDisplayResolver().getGradeOptions())
export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{
teacherNames: Map<string, string | null>
subjectNames: Map<string, string>
gradeNames: Map<string, string>
}> => {
const resolver = getCourseDisplayResolver()
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const [userMap, subjects, grades] = await Promise.all([
getUserNamesByIds(teacherIds),
getSubjectOptions(),
getGradeOptions(),
resolver.getUserNamesByIds(teacherIds),
getCachedSubjectOptions(),
getCachedGradeOptions(),
])
const teacherNames = new Map<string, string | null>()
@@ -189,10 +197,10 @@ export async function createElectiveCourse(
enrolledCount: 0,
classroom: data.classroom,
schedule: data.schedule,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
selectionStartAt: data.selectionStartAt ? new Date(data.selectionStartAt) : null,
selectionEndAt: data.selectionEndAt ? new Date(data.selectionEndAt) : null,
startDate: data.startDate ? safeParseDate(data.startDate, "开始日期") : null,
endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null,
selectionStartAt: data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null,
selectionEndAt: data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null,
status: "draft",
selectionMode: data.selectionMode,
credit: data.credit,
@@ -214,13 +222,13 @@ export async function updateElectiveCourse(
if (data.classroom !== undefined) update.classroom = data.classroom
if (data.schedule !== undefined) update.schedule = data.schedule
if (data.startDate !== undefined)
update.startDate = data.startDate ? new Date(data.startDate) : null
update.startDate = data.startDate ? safeParseDate(data.startDate, "开始日期") : null
if (data.endDate !== undefined)
update.endDate = data.endDate ? new Date(data.endDate) : null
update.endDate = data.endDate ? safeParseDate(data.endDate, "结束日期") : null
if (data.selectionStartAt !== undefined)
update.selectionStartAt = data.selectionStartAt ? new Date(data.selectionStartAt) : null
update.selectionStartAt = data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null
if (data.selectionEndAt !== undefined)
update.selectionEndAt = data.selectionEndAt ? new Date(data.selectionEndAt) : null
update.selectionEndAt = data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null
if (data.status !== undefined) update.status = data.status
if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode
if (data.credit !== undefined) update.credit = data.credit

View File

@@ -0,0 +1,102 @@
import "server-only"
import { getTranslations } from "next-intl/server"
import { exportToExcel } from "@/shared/lib/excel"
import { getElectiveCourses } from "./data-access"
import { getCourseSelections } from "./data-access-selections"
/**
* 导出选修课课程列表到 Excel
* Sheet 1: 课程明细
*/
export async function exportElectiveCoursesToExcel(params: {
status?: string
teacherId?: string
}): Promise<Buffer> {
const t = await getTranslations("elective")
const courses = await getElectiveCourses({
status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined,
teacherId: params.teacherId,
})
const rows = courses.map((c) => ({
[t("fields.name")]: c.name,
[t("fields.teacher")]: c.teacherName ?? "",
[t("fields.subject")]: c.subjectName ?? "",
[t("fields.grade")]: c.gradeName ?? "",
[t("fields.capacity")]: c.capacity,
[t("fields.enrolled")]: c.enrolledCount,
[t("fields.classroom")]: c.classroom ?? "",
[t("fields.schedule")]: c.schedule ?? "",
[t("fields.credit")]: c.credit,
[t("fields.selectionMode")]: t(`selectionMode.${c.selectionMode}`),
status: t(`status.${c.status}`),
[t("fields.startDate")]: c.startDate ?? "",
[t("fields.endDate")]: c.endDate ?? "",
}))
return exportToExcel({
sheets: [
{
name: t("title.adminList"),
columns: [
{ header: t("fields.name"), key: t("fields.name"), width: 24 },
{ header: t("fields.teacher"), key: t("fields.teacher"), width: 16 },
{ header: t("fields.subject"), key: t("fields.subject"), width: 14 },
{ header: t("fields.grade"), key: t("fields.grade"), width: 12 },
{ header: t("fields.capacity"), key: t("fields.capacity"), width: 10 },
{ header: t("fields.enrolled"), key: t("fields.enrolled"), width: 10 },
{ header: t("fields.classroom"), key: t("fields.classroom"), width: 14 },
{ header: t("fields.schedule"), key: t("fields.schedule"), width: 20 },
{ header: t("fields.credit"), key: t("fields.credit"), width: 8 },
{ header: t("fields.selectionMode"), key: t("fields.selectionMode"), width: 16 },
{ header: "Status", key: "status", width: 12 },
{ header: t("fields.startDate"), key: t("fields.startDate"), width: 14 },
{ header: t("fields.endDate"), key: t("fields.endDate"), width: 14 },
],
rows,
},
],
})
}
/**
* 导出课程选课名单到 Excel
* Sheet 1: 选课名单
*/
export async function exportCourseSelectionsToExcel(params: {
courseId: string
}): Promise<Buffer> {
const t = await getTranslations("elective")
const selections = await getCourseSelections(params.courseId)
const rows = selections.map((s, idx) => ({
"#": idx + 1,
[t("fields.name")]: s.studentName ?? "",
status: t(`selectionStatus.${s.status}`),
priority: s.priority ?? 1,
selectedAt: s.selectedAt.split("T")[0],
enrolledAt: s.enrolledAt ? s.enrolledAt.split("T")[0] : "",
}))
return exportToExcel({
sheets: [
{
name: t("student.mySelections"),
columns: [
{ header: "#", key: "#", width: 6 },
{ header: t("fields.name"), key: t("fields.name"), width: 18 },
{ header: "Status", key: "status", width: 12 },
{ header: "Priority", key: "priority", width: 10 },
{ header: "Selected At", key: "selectedAt", width: 14 },
{ header: "Enrolled At", key: "enrolledAt", width: 14 },
],
rows,
},
],
})
}

View File

@@ -0,0 +1,83 @@
import "server-only"
/**
* 选修课模块跨模块依赖的接口抽象P2-2.1.4)。
*
* 目的:将 elective 对 school/users/classes 模块的直接 import 收敛到此文件,
* 便于单测时 mock 单一入口,未来替换实现只需改此文件。
*
* 注意:实际实现仍委托给各模块的 data-access此处仅做接口聚合。
*/
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getStudentActiveGradeId } from "@/modules/classes/data-access"
/** 科目/年级/教师名称解析接口 */
export interface CourseDisplayResolver {
/** 根据教师 ID 列表获取用户名映射 */
getUserNamesByIds: (ids: string[]) => Promise<Map<string, { name: string | null }>>
/** 获取所有科目选项 */
getSubjectOptions: () => Promise<Array<{ id: string; name: string }>>
/** 获取所有年级选项 */
getGradeOptions: () => Promise<Array<{ id: string; name: string }>>
}
/** 学生年级解析接口 */
export interface StudentGradeResolver {
/** 获取学生当前激活的年级 ID */
getStudentActiveGradeId: (studentId: string) => Promise<string | null>
}
/**
* 默认实现:委托给各模块的 data-access。
* 单测时可注入 mock 实现替换。
*/
export const defaultCourseDisplayResolver: CourseDisplayResolver = {
getUserNamesByIds,
getSubjectOptions,
getGradeOptions,
}
export const defaultStudentGradeResolver: StudentGradeResolver = {
getStudentActiveGradeId,
}
/**
* 可注入的解析器实例(单测时可覆盖)。
* 使用闭包而非全局可变变量,避免并发测试污染。
*/
let courseDisplayResolver: CourseDisplayResolver = defaultCourseDisplayResolver
let studentGradeResolver: StudentGradeResolver = defaultStudentGradeResolver
/**
* 获取当前注入的课程显示名称解析器。
*/
export function getCourseDisplayResolver(): CourseDisplayResolver {
return courseDisplayResolver
}
/**
* 获取当前注入的学生年级解析器。
*/
export function getStudentGradeResolver(): StudentGradeResolver {
return studentGradeResolver
}
/**
* 注入自定义解析器(仅用于测试)。
* 调用后需在测试结束后调用 `resetResolvers()` 恢复默认实现。
*/
export function setCourseDisplayResolver(resolver: CourseDisplayResolver): void {
courseDisplayResolver = resolver
}
export function setStudentGradeResolver(resolver: StudentGradeResolver): void {
studentGradeResolver = resolver
}
/** 恢复默认解析器(测试 teardown 调用) */
export function resetResolvers(): void {
courseDisplayResolver = defaultCourseDisplayResolver
studentGradeResolver = defaultStudentGradeResolver
}