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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
30
src/modules/elective/components/elective-page-layout.tsx
Normal file
30
src/modules/elective/components/elective-page-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
102
src/modules/elective/export.ts
Normal file
102
src/modules/elective/export.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
83
src/modules/elective/resolvers.ts
Normal file
83
src/modules/elective/resolvers.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user