feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components
dashboard: - Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter - Add parent-dashboard components directory - Add config, hooks, and services directories diagnostic: - Add role-config and services directory elective: - Add elective-course-detail, elective-stats-cards, parent-selection-view components - Add data-access-settings and data-access-stats
This commit is contained in:
@@ -23,15 +23,25 @@ import {
|
||||
openSelection,
|
||||
closeSelection,
|
||||
} from "./data-access"
|
||||
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
|
||||
import {
|
||||
runLottery,
|
||||
selectCourse,
|
||||
dropCourse,
|
||||
ElectiveBusinessError,
|
||||
type ElectiveErrorCode,
|
||||
} from "./data-access-operations"
|
||||
import { COURSE_SELECTION_STATUS_LABEL_KEYS } from "./constants"
|
||||
|
||||
const revalidateElectivePaths = (id?: string) => {
|
||||
revalidatePath("/admin/elective")
|
||||
revalidatePath("/teacher/elective")
|
||||
revalidatePath("/student/elective")
|
||||
revalidatePath("/parent/elective")
|
||||
if (id) {
|
||||
revalidatePath(`/admin/elective/${id}`)
|
||||
revalidatePath(`/admin/elective/${id}/edit`)
|
||||
revalidatePath(`/teacher/elective/${id}`)
|
||||
revalidatePath(`/teacher/elective/${id}/edit`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +51,24 @@ const requireCourseId = (formData: FormData): string => {
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ElectiveBusinessError 翻译为用户可见的 i18n 文案。
|
||||
* 在 catch 中调用,返回 null 表示非业务错误(交给 handleActionError)。
|
||||
*/
|
||||
async function translateBusinessError(
|
||||
e: unknown,
|
||||
t: Awaited<ReturnType<typeof getTranslations>>
|
||||
): Promise<string | null> {
|
||||
if (e instanceof ElectiveBusinessError) {
|
||||
const code = e.code satisfies ElectiveErrorCode
|
||||
if (e.params) {
|
||||
return t(`errors.${code}`, e.params)
|
||||
}
|
||||
return t(`errors.${code}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户对课程的管理权限(资源归属校验)。
|
||||
* - admin(scope=all):直接放行
|
||||
@@ -101,6 +129,9 @@ export async function createElectiveCourseAction(
|
||||
})
|
||||
return { success: true, message: t("messages.created"), data: id }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -153,6 +184,9 @@ export async function updateElectiveCourseAction(
|
||||
})
|
||||
return { success: true, message: t("messages.updated"), data: id }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -181,6 +215,9 @@ export async function deleteElectiveCourseAction(
|
||||
})
|
||||
return { success: true, message: t("messages.deleted") }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +246,9 @@ export async function openSelectionAction(
|
||||
})
|
||||
return { success: true, message: t("messages.selectionOpened") }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -237,6 +277,9 @@ export async function closeSelectionAction(
|
||||
})
|
||||
return { success: true, message: t("messages.selectionClosed") }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -279,6 +322,9 @@ export async function runLotteryAction(
|
||||
data: result,
|
||||
}
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -310,8 +356,13 @@ export async function selectCourseAction(
|
||||
targetType: "course_selection",
|
||||
properties: { status: result.status, priority: parsed.data.priority },
|
||||
})
|
||||
return { success: true, message: result.message, data: result.status }
|
||||
// 通过 i18n 翻译选课结果状态(result.status 已是 CourseSelectionStatus 类型)
|
||||
const statusKey = COURSE_SELECTION_STATUS_LABEL_KEYS[result.status]
|
||||
return { success: true, message: t(statusKey), data: result.status }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
@@ -333,7 +384,7 @@ export async function dropCourseAction(
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
await dropCourse(parsed.data.courseId, ctx.userId)
|
||||
await dropCourse(parsed.data.courseId, ctx.userId, parsed.data.dropReason)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.course_dropped",
|
||||
@@ -343,6 +394,9 @@ export async function dropCourseAction(
|
||||
})
|
||||
return { success: true, message: t("messages.courseDropped") }
|
||||
} catch (e) {
|
||||
const t = await getTranslations("elective")
|
||||
const translated = await translateBusinessError(e, t)
|
||||
if (translated) return { success: false, message: translated }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
198
src/modules/elective/components/elective-course-detail.tsx
Normal file
198
src/modules/elective/components/elective-course-detail.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ArrowLeft, Pencil, Users } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
import {
|
||||
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
|
||||
COURSE_SELECTION_STATUS_LABEL_KEYS,
|
||||
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||
ELECTIVE_STATUS_LABEL_KEYS,
|
||||
SELECTION_MODE_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type {
|
||||
CourseSelectionWithDetails,
|
||||
ElectiveCourseWithDetails,
|
||||
} from "../types"
|
||||
|
||||
/**
|
||||
* 选修课程详情视图(admin/teacher 共用)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 通过 props 注入数据,不直接调用 data-access(便于测试与复用)
|
||||
* - 组合优先:分成课程信息卡片 + 选课名单表格两个独立区块
|
||||
* - 编辑按钮通过 editHref 参数化,admin/teacher 路由各自传入
|
||||
*/
|
||||
export function ElectiveCourseDetail({
|
||||
course,
|
||||
selections,
|
||||
editHref,
|
||||
backHref,
|
||||
showEditButton = true,
|
||||
}: {
|
||||
course: ElectiveCourseWithDetails
|
||||
selections: CourseSelectionWithDetails[]
|
||||
editHref?: string
|
||||
backHref: string
|
||||
showEditButton?: boolean
|
||||
}) {
|
||||
const t = useTranslations("elective")
|
||||
|
||||
const activeSelections = selections.filter((s) =>
|
||||
["selected", "enrolled", "waitlist"].includes(s.status)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
{t("detail.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
{showEditButton && editHref ? (
|
||||
<Button asChild size="sm">
|
||||
<Link href={editHref}>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
{t("detail.editCourse")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl">{course.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description.detail")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailField label={t("fields.subject")} value={course.subjectName} />
|
||||
<DetailField label={t("fields.grade")} value={course.gradeName} />
|
||||
<DetailField label={t("fields.teacher")} value={course.teacherName} />
|
||||
<DetailField
|
||||
label={t("fields.capacity")}
|
||||
value={`${course.enrolledCount} / ${course.capacity}`}
|
||||
/>
|
||||
<DetailField label={t("fields.classroom")} value={course.classroom} />
|
||||
<DetailField
|
||||
label={t("fields.selectionMode")}
|
||||
value={t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
|
||||
/>
|
||||
<DetailField label={t("fields.credit")} value={course.credit} />
|
||||
<DetailField label={t("fields.startDate")} value={course.startDate} />
|
||||
<DetailField label={t("fields.endDate")} value={course.endDate} />
|
||||
<DetailField label={t("fields.selectionStart")} value={course.selectionStartAt} />
|
||||
<DetailField label={t("fields.selectionEnd")} value={course.selectionEndAt} />
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("fields.schedule")}</p>
|
||||
<p className="mt-1 text-sm">{course.schedule}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{course.description ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("fields.description")}</p>
|
||||
<p className="mt-1 text-sm">{course.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Users className="h-5 w-5" />
|
||||
{t("detail.studentsTitle")}
|
||||
</CardTitle>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("detail.noStudents")}
|
||||
description={t("detail.noStudentsDescription")}
|
||||
icon={Users}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4 font-medium">#</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("detail.studentName")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("export.statusHeader")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("detail.priority")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("detail.selectedAt")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("detail.enrolledAt")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeSelections.map((sel, idx) => (
|
||||
<tr key={sel.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 text-muted-foreground">{idx + 1}</td>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{sel.studentName ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 tabular-nums">
|
||||
{sel.priority ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{sel.selectedAt
|
||||
? new Date(sel.selectedAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{sel.enrolledAt
|
||||
? new Date(sel.enrolledAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string | null | undefined
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium">{value ?? "—"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -69,20 +70,21 @@ export function ElectiveCourseForm({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error("Invalid form state")
|
||||
toast.error(t("form.invalidFormState"))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
// 根据 backHref 推断返回列表页路径
|
||||
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
|
||||
router.push(redirectBase)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to save course")
|
||||
toast.error(res.message || t("form.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save course")
|
||||
toast.error(t("form.saveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -92,14 +94,14 @@ export function ElectiveCourseForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{mode === "create" ? "New Elective Course" : "Edit Elective Course"}
|
||||
{mode === "create" ? t("form.createTitle") : t("form.editTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Course Name *</Label>
|
||||
<Label htmlFor="name">{t("form.nameLabel")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
@@ -109,10 +111,10 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Subject</Label>
|
||||
<Label>{t("form.subjectLabel")}</Label>
|
||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
<SelectValue placeholder={t("form.selectSubjectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
@@ -126,10 +128,10 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Grade</Label>
|
||||
<Label>{t("form.gradeLabel")}</Label>
|
||||
<Select value={gradeId} onValueChange={setGradeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a grade" />
|
||||
<SelectValue placeholder={t("form.selectGradePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{grades.map((g) => (
|
||||
@@ -143,15 +145,15 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Teacher</Label>
|
||||
<Label>{t("form.teacherLabel")}</Label>
|
||||
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a teacher" />
|
||||
<SelectValue placeholder={t("form.selectTeacherPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{teachers.map((teacher) => (
|
||||
<SelectItem key={teacher.id} value={teacher.id}>
|
||||
{teacher.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -160,7 +162,7 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="capacity">Capacity</Label>
|
||||
<Label htmlFor="capacity">{t("form.capacityLabel")}</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
@@ -172,7 +174,7 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="classroom">Classroom</Label>
|
||||
<Label htmlFor="classroom">{t("form.classroomLabel")}</Label>
|
||||
<Input
|
||||
id="classroom"
|
||||
name="classroom"
|
||||
@@ -181,17 +183,17 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="schedule">Schedule</Label>
|
||||
<Label htmlFor="schedule">{t("form.scheduleLabel")}</Label>
|
||||
<Input
|
||||
id="schedule"
|
||||
name="schedule"
|
||||
placeholder="e.g. Mon 14:00-15:30"
|
||||
placeholder={t("form.schedulePlaceholder")}
|
||||
defaultValue={course?.schedule ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="credit">Credit</Label>
|
||||
<Label htmlFor="credit">{t("form.creditLabel")}</Label>
|
||||
<Input
|
||||
id="credit"
|
||||
name="credit"
|
||||
@@ -222,7 +224,7 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Label htmlFor="startDate">{t("form.startDateLabel")}</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
@@ -232,7 +234,7 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">End Date</Label>
|
||||
<Label htmlFor="endDate">{t("form.endDateLabel")}</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
@@ -242,7 +244,7 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="selectionStartAt">Selection Start</Label>
|
||||
<Label htmlFor="selectionStartAt">{t("form.selectionStartLabel")}</Label>
|
||||
<Input
|
||||
id="selectionStartAt"
|
||||
name="selectionStartAt"
|
||||
@@ -256,7 +258,7 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="selectionEndAt">Selection End</Label>
|
||||
<Label htmlFor="selectionEndAt">{t("form.selectionEndLabel")}</Label>
|
||||
<Input
|
||||
id="selectionEndAt"
|
||||
name="selectionEndAt"
|
||||
@@ -268,14 +270,32 @@ export function ElectiveCourseForm({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* P2-4:退课截止时间 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dropDeadline">{t("form.dropDeadlineLabel")}</Label>
|
||||
<Input
|
||||
id="dropDeadline"
|
||||
name="dropDeadline"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
course?.dropDeadline
|
||||
? new Date(course.dropDeadline).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("form.dropDeadlineHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Label htmlFor="description">{t("form.descriptionLabel")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Course description..."
|
||||
placeholder={t("form.descriptionPlaceholder")}
|
||||
className="min-h-[80px]"
|
||||
defaultValue={course?.description ?? ""}
|
||||
/>
|
||||
@@ -285,13 +305,12 @@ export function ElectiveCourseForm({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(backHref ?? "/admin/elective")}
|
||||
disabled={isWorking}
|
||||
asChild
|
||||
>
|
||||
Cancel
|
||||
<Link href={backHref ?? "/admin/elective"}>{t("form.cancelButton")}</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||
{isWorking ? t("form.savingButton") : mode === "create" ? t("form.createButton") : t("form.saveButton")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
|
||||
@@ -89,10 +90,10 @@ export function ElectiveCourseList({
|
||||
</p>
|
||||
{manageResolved && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Link href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("actions.create")}
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -174,10 +175,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<a href={`${editBaseHref}/${course.id}/edit`}>
|
||||
<Link href={`${editBaseHref}/${course.id}/edit`}>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{course.status === "draft" || course.status === "closed" ? (
|
||||
|
||||
71
src/modules/elective/components/elective-stats-cards.tsx
Normal file
71
src/modules/elective/components/elective-stats-cards.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ElectiveOverviewStats } from "../data-access-stats"
|
||||
|
||||
/**
|
||||
* 选课模块管理员概览统计卡片网格。
|
||||
*
|
||||
* 复用模式:与 attendance-stats-cards 一致的 4 卡片网格。
|
||||
* 设计原则:
|
||||
* - 通过 props 注入数据,不直接调用 data-access(便于测试与复用)
|
||||
* - 使用 i18n key 解析标题,value 由父组件传入已格式化的数据
|
||||
*/
|
||||
export function ElectiveStatsCards({ stats }: { stats: ElectiveOverviewStats }) {
|
||||
const t = useTranslations("elective")
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t("stats.totalCourses"),
|
||||
value: stats.totalCourses,
|
||||
icon: BookOpen,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.totalEnrolled"),
|
||||
value: stats.totalEnrolled,
|
||||
icon: Users,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.avgUtilization"),
|
||||
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
|
||||
icon: Gauge,
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
},
|
||||
{
|
||||
title: t("stats.pendingLottery"),
|
||||
value: stats.pendingLottery,
|
||||
icon: Shuffle,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
role="region"
|
||||
aria-label={t("stats.totalCourses")}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title} className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
|
||||
<card.icon className={cn("h-4 w-4", card.color)} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
src/modules/elective/components/parent-selection-view.tsx
Normal file
85
src/modules/elective/components/parent-selection-view.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
import {
|
||||
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
|
||||
COURSE_SELECTION_STATUS_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type { CourseSelectionWithDetails } from "../types"
|
||||
|
||||
/**
|
||||
* 家长视角下查看子女选课的只读视图。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 只读:家长不能替子女选/退课,仅展示已选记录
|
||||
* - 复用:复用 Badge variant 映射 + i18n key,与 StudentSelectionView 一致
|
||||
* - 安全:组件本身不依赖 studentId,由父页面注入已过滤的数据
|
||||
*/
|
||||
export function ParentSelectionView({
|
||||
selections,
|
||||
studentName,
|
||||
}: {
|
||||
selections: CourseSelectionWithDetails[]
|
||||
studentName: string
|
||||
}) {
|
||||
const t = useTranslations("elective")
|
||||
|
||||
const activeSelections = selections.filter((s) =>
|
||||
["selected", "enrolled", "waitlist"].includes(s.status)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{studentName}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("parent.noRecordsTitle")}
|
||||
description={t("parent.noRecordsDescription")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeSelections.map((sel) => (
|
||||
<Card key={sel.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? t("errors.notFound")}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{sel.lotteryRank}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.selectedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("export.selectedAtHeader")}: {new Date(sel.selectedAt).toLocaleDateString()}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
|
||||
import {
|
||||
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
|
||||
@@ -35,24 +37,171 @@ import type {
|
||||
} from "../types"
|
||||
import { selectCourseAction, dropCourseAction } from "../actions"
|
||||
|
||||
export function StudentSelectionView({
|
||||
availableCourses,
|
||||
/**
|
||||
* 已选课程区块(我的选课)
|
||||
* - 独立 Suspense 边界内的客户端组件
|
||||
* - 仅负责"退课"操作与展示
|
||||
*/
|
||||
export function StudentMySelectionsSection({
|
||||
mySelections,
|
||||
}: {
|
||||
availableCourses: ElectiveCourseWithDetails[]
|
||||
mySelections: CourseSelectionWithDetails[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
// P2-4:退课理由输入(按课程 ID 隔离,便于多卡片独立填写)
|
||||
const [dropReasonMap, setDropReasonMap] = useState<Record<string, string>>({})
|
||||
|
||||
const activeSelections = mySelections.filter((s) =>
|
||||
["selected", "enrolled", "waitlist"].includes(s.status)
|
||||
)
|
||||
const selectedCourseIds = new Set(
|
||||
activeSelections.map((s) => s.courseId)
|
||||
|
||||
const handleDrop = (courseId: string) => {
|
||||
setPendingId(courseId)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
// P2-4:透传退课理由
|
||||
const reason = dropReasonMap[courseId]
|
||||
if (reason && reason.trim().length > 0) {
|
||||
formData.set("dropReason", reason.trim())
|
||||
}
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("student.dropSuccess"))
|
||||
router.refresh()
|
||||
// 清空该课程的退课理由
|
||||
setDropReasonMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[courseId]
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4" aria-labelledby="student-my-selections-heading">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3
|
||||
id="student-my-selections-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("student.mySelections")}
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.empty")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeSelections.map((sel) => (
|
||||
<Card key={sel.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? t("errors.notFound")}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{sel.lotteryRank}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("actions.drop")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("student.confirmDrop")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{/* P2-4:可选退课理由输入 */}
|
||||
<div className="grid gap-2 py-2">
|
||||
<Label htmlFor={`dropReason-${sel.courseId}`}>
|
||||
{t("fields.dropReason")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`dropReason-${sel.courseId}`}
|
||||
value={dropReasonMap[sel.courseId] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDropReasonMap((prev) => ({
|
||||
...prev,
|
||||
[sel.courseId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t("student.dropReasonPlaceholder")}
|
||||
className="min-h-[60px]"
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
{t("actions.drop")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选课程区块
|
||||
* - 独立 Suspense 边界内的客户端组件
|
||||
* - 仅负责"选课"操作与展示
|
||||
* - 通过 selectedCourseIds prop 接收已选课程 ID 集合,避免与已选区块直接耦合
|
||||
*/
|
||||
export function StudentAvailableCoursesSection({
|
||||
availableCourses,
|
||||
selectedCourseIds,
|
||||
}: {
|
||||
availableCourses: ElectiveCourseWithDetails[]
|
||||
selectedCourseIds: Set<string>
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleSelect = (courseId: string) => {
|
||||
setPendingId(courseId)
|
||||
@@ -70,179 +219,93 @@ export function StudentSelectionView({
|
||||
})
|
||||
}
|
||||
|
||||
const handleDrop = (courseId: string) => {
|
||||
setPendingId(courseId)
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("student.dropSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.empty")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeSelections.map((sel) => (
|
||||
<Card key={sel.id}>
|
||||
<section className="space-y-4" aria-labelledby="student-available-courses-heading">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3
|
||||
id="student-available-courses-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("student.availableCourses")}
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{availableCourses.length}
|
||||
</span>
|
||||
</div>
|
||||
{availableCourses.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.emptyStudent")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{availableCourses.map((course) => {
|
||||
const isFull = course.enrolledCount >= course.capacity
|
||||
const alreadySelected = selectedCourseIds.has(course.id)
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? t("errors.notFound")}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
|
||||
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
<span>{t("fields.credit")}: {course.credit}</span>
|
||||
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
|
||||
</div>
|
||||
{course.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{course.description}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{sel.lotteryRank}
|
||||
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("actions.drop")}
|
||||
<div className="mt-auto pt-2">
|
||||
{alreadySelected ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t("student.selected")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("student.confirmDrop")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
{t("actions.drop")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleSelect(course.id)}
|
||||
>
|
||||
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{availableCourses.length}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{availableCourses.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.emptyStudent")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{availableCourses.map((course) => {
|
||||
const isFull = course.enrolledCount >= course.capacity
|
||||
const alreadySelected = selectedCourseIds.has(course.id)
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
<span>{t("fields.credit")}: {course.credit}</span>
|
||||
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
|
||||
</div>
|
||||
{course.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{course.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto pt-2">
|
||||
{alreadySelected ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t("student.selected")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleSelect(course.id)}
|
||||
>
|
||||
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,48 @@ import "server-only"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
courseSelections,
|
||||
electiveCourses,
|
||||
} from "@/shared/db/schema"
|
||||
import { BusinessError } from "@/shared/lib/action-utils"
|
||||
import { sendNotification } from "@/modules/notifications"
|
||||
|
||||
import { getElectiveCreditLimit, getCapacityNotifyThreshold } from "./data-access-settings"
|
||||
import { getStudentGradeId } from "./data-access-selections"
|
||||
import type { CourseSelectionStatus } from "./types"
|
||||
|
||||
/** 学分上限(K12 选修课学期学分上限,可按需调整) */
|
||||
const MAX_CREDIT_PER_TERM = 10
|
||||
/**
|
||||
* 选课模块业务错误码(与 i18n key `errors.*` 对应)。
|
||||
* 由 actions 层根据 code 通过 getTranslations 翻译为用户可见文案。
|
||||
*/
|
||||
export type ElectiveErrorCode =
|
||||
| "courseNotFound"
|
||||
| "selectionNotOpen"
|
||||
| "selectionNotStarted"
|
||||
| "selectionEnded"
|
||||
| "alreadySelected"
|
||||
| "scheduleConflict"
|
||||
| "creditExceeded"
|
||||
| "noActiveSelection"
|
||||
| "dropDeadlinePassed"
|
||||
|
||||
/**
|
||||
* 选课模块业务错误(带 i18n code 与参数)。
|
||||
* actions 层捕获后用 getTranslations(`elective.errors.${code}`) 翻译。
|
||||
*/
|
||||
export class ElectiveBusinessError extends BusinessError {
|
||||
constructor(
|
||||
public readonly code: ElectiveErrorCode,
|
||||
public readonly params?: Record<string, string | number>
|
||||
) {
|
||||
super(`elective.errors.${code}`, code)
|
||||
this.name = "ElectiveBusinessError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
|
||||
@@ -25,35 +56,76 @@ export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析课程 schedule 字段为可比较的时间段(纯函数,便于测试)。
|
||||
* schedule 格式约定:"周一 14:00-15:30" 或 "Mon 14:00-15:30"。
|
||||
* 返回 null 表示无法解析(不参与冲突检测)。
|
||||
* 星期字符串归一化映射(纯函数,便于测试)。
|
||||
* 支持中英文全称与缩写,统一映射为 1-7 数字字符串。
|
||||
*/
|
||||
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 ?? "" }
|
||||
const DAY_NORMALIZE_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
// 中文
|
||||
"周一": "1", "周二": "2", "周三": "3", "周四": "4",
|
||||
"周五": "5", "周六": "6", "周日": "7", "周天": "7",
|
||||
"星期一": "1", "星期二": "2", "星期三": "3", "星期四": "4",
|
||||
"星期五": "5", "星期六": "6", "星期日": "7", "星期天": "7",
|
||||
// 英文全称
|
||||
monday: "1", tuesday: "2", wednesday: "3", thursday: "4",
|
||||
friday: "5", saturday: "6", sunday: "7",
|
||||
// 英文缩写
|
||||
mon: "1", tue: "2", wed: "3", thu: "4",
|
||||
fri: "5", sat: "6", sun: "7",
|
||||
})
|
||||
|
||||
export function normalizeDay(day: string): string {
|
||||
return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测两个时间段是否冲突(纯函数,便于测试)。
|
||||
* 仅当 day 相同且时间区间重叠时判定为冲突。
|
||||
* 解析课程 schedule 字段为可比较的时间段数组(纯函数,便于测试)。
|
||||
*
|
||||
* 支持多时段(以逗号或分号分隔),例如:
|
||||
* - "周一 14:00-15:30"
|
||||
* - "Mon 14:00-15:30, Wed 16:00-17:30"
|
||||
* - "周一 14:00-15:30;周三 16:00-17:30"
|
||||
*
|
||||
* 返回空数组表示无法解析(不参与冲突检测)。
|
||||
*/
|
||||
export function parseSchedule(
|
||||
schedule: string | null
|
||||
): Array<{ day: string; start: string; end: string }> {
|
||||
if (!schedule || schedule.length === 0) return []
|
||||
|
||||
// 按逗号、分号、中文分号拆分多个时段
|
||||
const segments = schedule.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean)
|
||||
const result: Array<{ day: string; start: string; end: string }> = []
|
||||
|
||||
// 支持中英文星期与英文全称/缩写
|
||||
const dayPattern = "周[一二三四五六日天]|星期[一二三四五六日天]|[Mm]on(?:day)?|[Tt]ue(?:sday)?|[Ww]ed(?:nesday)?|[Tt]hu(?:rsday)?|[Ff]ri(?:day)?|[Ss]at(?:urday)?|[Ss]un(?:day)?"
|
||||
const timePattern = "(\\d{1,2}):(\\d{2})"
|
||||
const re = new RegExp(
|
||||
`^(${dayPattern})\\s+${timePattern}\\s*[-~~至到]\\s*${timePattern}$`,
|
||||
"i"
|
||||
)
|
||||
|
||||
for (const seg of segments) {
|
||||
const match = seg.match(re)
|
||||
if (!match) continue
|
||||
const [, day, startH, startM, endH, endM] = match
|
||||
if (!day || !startH || !startM || !endH || !endM) continue
|
||||
result.push({
|
||||
day,
|
||||
start: `${startH.padStart(2, "0")}:${startM}`,
|
||||
end: `${endH.padStart(2, "0")}:${endM}`,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测两组时间段是否存在冲突(纯函数,便于测试)。
|
||||
* 仅当 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
|
||||
}
|
||||
@@ -72,8 +144,8 @@ async function checkScheduleConflict(
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, newCourseId))
|
||||
.limit(1)
|
||||
const newSchedule = parseSchedule(newCourse?.schedule ?? null)
|
||||
if (!newSchedule) return false
|
||||
const newSlots = parseSchedule(newCourse?.schedule ?? null)
|
||||
if (newSlots.length === 0) return false
|
||||
|
||||
const existingCourses = await tx
|
||||
.select({
|
||||
@@ -89,22 +161,32 @@ async function checkScheduleConflict(
|
||||
)
|
||||
|
||||
for (const row of existingCourses) {
|
||||
const existing = parseSchedule(row.schedule)
|
||||
if (existing && isScheduleConflict(newSchedule, existing)) {
|
||||
return true
|
||||
const existingSlots = parseSchedule(row.schedule)
|
||||
for (const newSlot of newSlots) {
|
||||
for (const existingSlot of existingSlots) {
|
||||
if (isScheduleConflict(newSlot, existingSlot)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测学生学分是否超限(P2 建议:学分上限校验)。
|
||||
* 检测学生学分是否超限(P2-4 重构:使用 system_settings 配置化上限)。
|
||||
* 查询学生已选课程的学分总和,加上新课程学分后是否超过上限。
|
||||
*
|
||||
* 上限来源(按优先级):
|
||||
* 1. `creditLimit:grade:<studentGradeId>`(按年级配置)
|
||||
* 2. `creditLimit:default`(全局配置)
|
||||
* 3. 默认值 10
|
||||
*/
|
||||
async function checkCreditLimit(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
studentId: string,
|
||||
newCourseId: string
|
||||
newCourseId: string,
|
||||
studentGradeId: string | null
|
||||
): Promise<{ exceeded: boolean; current: number; max: number }> {
|
||||
const [newCourse] = await tx
|
||||
.select({ credit: electiveCourses.credit })
|
||||
@@ -128,10 +210,12 @@ async function checkCreditLimit(
|
||||
|
||||
const currentCredit = existing.reduce((sum, row) => sum + Number(row.credit ?? 0), 0)
|
||||
const total = currentCredit + newCredit
|
||||
// 配置化上限:按年级或全局,默认 10
|
||||
const max = await getElectiveCreditLimit(studentGradeId)
|
||||
return {
|
||||
exceeded: total > MAX_CREDIT_PER_TERM,
|
||||
exceeded: total > max,
|
||||
current: total,
|
||||
max: MAX_CREDIT_PER_TERM,
|
||||
max,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +241,7 @@ export async function runLottery(courseId: string): Promise<{
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)),
|
||||
])
|
||||
const course = courseRows[0]
|
||||
if (!course) throw new Error("Course not found")
|
||||
if (!course) throw new ElectiveBusinessError("courseNotFound")
|
||||
|
||||
if (selections.length === 0) {
|
||||
return { enrolled: 0, waitlist: 0 }
|
||||
@@ -185,6 +269,8 @@ export async function runLottery(courseId: string): Promise<{
|
||||
const enrolledCount = enrolledIds.length
|
||||
const waitlistCount = waitlistIds.length
|
||||
|
||||
// P1-12 改进:抽签后不强制 close 课程,保留 status="open" 以便管理员重抽。
|
||||
// 管理员可手动通过 closeSelection 关闭选课。
|
||||
await db.transaction(async (tx) => {
|
||||
if (enrolledIds.length > 0) {
|
||||
await tx
|
||||
@@ -207,9 +293,10 @@ export async function runLottery(courseId: string): Promise<{
|
||||
})
|
||||
.where(inArray(courseSelections.id, waitlistIds))
|
||||
}
|
||||
// 仅更新 enrolledCount,不自动关闭课程
|
||||
await tx
|
||||
.update(electiveCourses)
|
||||
.set({ enrolledCount, status: "closed", updatedAt: now })
|
||||
.set({ enrolledCount, updatedAt: now })
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
})
|
||||
|
||||
@@ -220,7 +307,10 @@ export async function selectCourse(
|
||||
courseId: string,
|
||||
studentId: string,
|
||||
priority?: number
|
||||
): Promise<{ status: CourseSelectionStatus; message: string }> {
|
||||
): Promise<{ status: CourseSelectionStatus }> {
|
||||
// P2-4:先查询学生年级 ID(用于按年级配置的学分上限)
|
||||
const studentGradeId = await getStudentGradeId(studentId)
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
// 锁定课程行,防止 FCFS 模式下并发超卖
|
||||
const [course] = await tx
|
||||
@@ -229,15 +319,15 @@ export async function selectCourse(
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.for("update")
|
||||
.limit(1)
|
||||
if (!course) throw new Error("Course not found")
|
||||
if (course.status !== "open") throw new Error("Course selection is not open")
|
||||
if (!course) throw new ElectiveBusinessError("courseNotFound")
|
||||
if (course.status !== "open") throw new ElectiveBusinessError("selectionNotOpen")
|
||||
|
||||
const now = new Date()
|
||||
if (course.selectionStartAt && now < course.selectionStartAt) {
|
||||
throw new Error("Selection has not started yet")
|
||||
throw new ElectiveBusinessError("selectionNotStarted")
|
||||
}
|
||||
if (course.selectionEndAt && now > course.selectionEndAt) {
|
||||
throw new Error("Selection has ended")
|
||||
throw new ElectiveBusinessError("selectionEnded")
|
||||
}
|
||||
|
||||
const [existing] = await tx
|
||||
@@ -251,18 +341,21 @@ export async function selectCourse(
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (existing) throw new Error("Already selected this course")
|
||||
if (existing) throw new ElectiveBusinessError("alreadySelected")
|
||||
|
||||
// P2 建议:选课时间冲突检测
|
||||
const hasConflict = await checkScheduleConflict(tx, studentId, courseId)
|
||||
if (hasConflict) {
|
||||
throw new Error("Schedule conflicts with your existing courses")
|
||||
throw new ElectiveBusinessError("scheduleConflict")
|
||||
}
|
||||
|
||||
// P2 建议:学分上限校验
|
||||
const creditCheck = await checkCreditLimit(tx, studentId, courseId)
|
||||
// P2-4:学分上限校验(使用按年级配置的上限)
|
||||
const creditCheck = await checkCreditLimit(tx, studentId, courseId, studentGradeId)
|
||||
if (creditCheck.exceeded) {
|
||||
throw new Error(`Credit limit exceeded (${creditCheck.current}/${creditCheck.max})`)
|
||||
throw new ElectiveBusinessError("creditExceeded", {
|
||||
current: creditCheck.current,
|
||||
max: creditCheck.max,
|
||||
})
|
||||
}
|
||||
|
||||
const id = createId()
|
||||
@@ -272,13 +365,18 @@ export async function selectCourse(
|
||||
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
|
||||
status = "enrolled"
|
||||
enrolledAt = now
|
||||
const newEnrolledCount = course.enrolledCount + 1
|
||||
await tx
|
||||
.update(electiveCourses)
|
||||
.set({
|
||||
enrolledCount: course.enrolledCount + 1,
|
||||
enrolledCount: newEnrolledCount,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
|
||||
// P2-4:容量阈值通知(fire-and-forget,不阻塞事务)
|
||||
// 仅在跨过阈值时触发(避免每次选课都通知)
|
||||
void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)
|
||||
} else if (course.selectionMode === "fcfs") {
|
||||
status = "waitlist"
|
||||
}
|
||||
@@ -293,21 +391,64 @@ export async function selectCourse(
|
||||
enrolledAt,
|
||||
})
|
||||
|
||||
return {
|
||||
status,
|
||||
message:
|
||||
status === "enrolled"
|
||||
? "Enrolled successfully"
|
||||
: status === "waitlist"
|
||||
? "Added to waitlist"
|
||||
: "Selection submitted",
|
||||
}
|
||||
return { status }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 容量阈值通知(P2-4 新增)。
|
||||
*
|
||||
* 触发条件:FCFS 模式下,录取后 `enrolledCount >= capacity * threshold`。
|
||||
* 阈值来自 system_settings(`capacityNotifyThreshold`,默认 0.9)。
|
||||
*
|
||||
* 通知接收者:课程创建者/教师(teacherId)。
|
||||
* 通知为 fire-and-forget,失败不影响选课流程。
|
||||
*
|
||||
* 防重复策略:仅当 `enrolledCount === Math.ceil(capacity * threshold)` 时触发,
|
||||
* 即只在跨过阈值的瞬间触发一次。
|
||||
*/
|
||||
async function notifyCapacityThresholdIfNeeded(
|
||||
course: { teacherId: string; id: string; name: string; capacity: number },
|
||||
newEnrolledCount: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const threshold = await getCapacityNotifyThreshold()
|
||||
const triggerPoint = Math.ceil(course.capacity * threshold)
|
||||
// 仅在跨过阈值瞬间触发(避免每次选课都通知)
|
||||
if (newEnrolledCount !== triggerPoint) return
|
||||
|
||||
// P2-4:通知文案使用 i18n 翻译键
|
||||
const t = await getTranslations("elective")
|
||||
const title = t("notifications.capacityWarningTitle", { courseName: course.name })
|
||||
const content = t("notifications.capacityWarningContent", {
|
||||
courseName: course.name,
|
||||
enrolled: newEnrolledCount,
|
||||
capacity: course.capacity,
|
||||
percent: Math.round(threshold * 100),
|
||||
})
|
||||
|
||||
await sendNotification({
|
||||
userId: course.teacherId,
|
||||
title,
|
||||
content,
|
||||
type: "warning",
|
||||
actionUrl: `/admin/elective/${course.id}`,
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
enrolledCount: newEnrolledCount,
|
||||
capacity: course.capacity,
|
||||
threshold,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget:通知失败不影响选课事务
|
||||
}
|
||||
}
|
||||
|
||||
export async function dropCourse(
|
||||
courseId: string,
|
||||
studentId: string
|
||||
studentId: string,
|
||||
dropReason?: string
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
@@ -321,7 +462,7 @@ export async function dropCourse(
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!existing) throw new Error("No active selection found")
|
||||
if (!existing) throw new ElectiveBusinessError("noActiveSelection")
|
||||
|
||||
// 锁定课程行,确保 enrolledCount 更新与候补递补的原子性
|
||||
const [course] = await tx
|
||||
@@ -332,9 +473,20 @@ export async function dropCourse(
|
||||
.limit(1)
|
||||
|
||||
const now = new Date()
|
||||
// P2-4:退课截止时间校验
|
||||
if (course?.dropDeadline && now > course.dropDeadline) {
|
||||
throw new ElectiveBusinessError("dropDeadlinePassed")
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(courseSelections)
|
||||
.set({ status: "dropped", droppedAt: now, updatedAt: now })
|
||||
.set({
|
||||
status: "dropped",
|
||||
droppedAt: now,
|
||||
// P2-4:记录退课理由(trim 后存入,空字符串转为 null)
|
||||
dropReason: dropReason && dropReason.trim().length > 0 ? dropReason.trim() : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(courseSelections.id, existing.id))
|
||||
|
||||
if (existing.status === "enrolled" && course && course.selectionMode === "fcfs") {
|
||||
|
||||
@@ -37,6 +37,7 @@ type SelectionCoreRow = {
|
||||
courseCapacity: number | null
|
||||
courseEnrolledCount: number | null
|
||||
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
|
||||
dropReason: string | null
|
||||
}
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
@@ -56,6 +57,7 @@ const mapSelectionRow = (
|
||||
selectedAt: toIsoRequired(r.selectedAt),
|
||||
enrolledAt: toIso(r.enrolledAt),
|
||||
droppedAt: toIso(r.droppedAt),
|
||||
dropReason: r.dropReason,
|
||||
lotteryRank: r.lotteryRank,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
@@ -77,6 +79,7 @@ const buildSelectionCoreSelect = () =>
|
||||
selectedAt: courseSelections.selectedAt,
|
||||
enrolledAt: courseSelections.enrolledAt,
|
||||
droppedAt: courseSelections.droppedAt,
|
||||
dropReason: courseSelections.dropReason,
|
||||
lotteryRank: courseSelections.lotteryRank,
|
||||
createdAt: courseSelections.createdAt,
|
||||
updatedAt: courseSelections.updatedAt,
|
||||
|
||||
97
src/modules/elective/data-access-settings.ts
Normal file
97
src/modules/elective/data-access-settings.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { systemSettings } from "@/shared/db/schema"
|
||||
|
||||
/**
|
||||
* 选课模块配置化设置(P2-4 新增)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 复用全局 `system_settings` 表(category="elective"),避免新增独立表
|
||||
* - 支持按年级覆盖(key=`creditLimit:grade:<gradeId>`),fallback 到全局(key=`creditLimit:default`)
|
||||
* - 用 React `cache()` 包装,单次请求内去重
|
||||
* - 配置缺失时使用默认值(向后兼容)
|
||||
*
|
||||
* 配置项:
|
||||
* - `creditLimit:default` / `creditLimit:grade:<gradeId>`:学期学分上限(默认 10)
|
||||
* - `capacityNotifyThreshold`:容量阈值通知比例 0-1(默认 0.9)
|
||||
*/
|
||||
|
||||
const SETTINGS_CATEGORY = "elective"
|
||||
|
||||
/** 默认学期学分上限(K12 选修课) */
|
||||
const DEFAULT_MAX_CREDIT_PER_TERM = 10
|
||||
/** 默认容量阈值通知比例(90%) */
|
||||
const DEFAULT_CAPACITY_NOTIFY_THRESHOLD = 0.9
|
||||
|
||||
/**
|
||||
* 读取 system_settings 中指定 key 的值。
|
||||
* 失败或未配置时返回 null(不抛错,保证向后兼容)。
|
||||
*/
|
||||
async function readSettingValue(key: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ value: systemSettings.value, valueType: systemSettings.valueType })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(systemSettings.category, SETTINGS_CATEGORY),
|
||||
eq(systemSettings.key, key)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row?.value ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学期学分上限(P2-4 新增)。
|
||||
*
|
||||
* 查询顺序:
|
||||
* 1. 若传入 gradeId,先查 `creditLimit:grade:<gradeId>`
|
||||
* 2. 若未配置或未传入 gradeId,fallback 到 `creditLimit:default`
|
||||
* 3. 都未配置则返回默认值 10
|
||||
*
|
||||
* @param gradeId 学生所在年级 ID(可选)
|
||||
*/
|
||||
export const getElectiveCreditLimit = cache(
|
||||
async (gradeId?: string | null): Promise<number> => {
|
||||
if (gradeId) {
|
||||
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
|
||||
if (gradeValue !== null) {
|
||||
const parsed = Number(gradeValue)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
}
|
||||
const defaultValue = await readSettingValue("creditLimit:default")
|
||||
if (defaultValue !== null) {
|
||||
const parsed = Number(defaultValue)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
return DEFAULT_MAX_CREDIT_PER_TERM
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取容量阈值通知比例(P2-4 新增)。
|
||||
*
|
||||
* 当课程 `enrolledCount >= capacity * threshold` 时触发管理员通知。
|
||||
* 默认 0.9(90%)。
|
||||
*/
|
||||
export const getCapacityNotifyThreshold = cache(
|
||||
async (): Promise<number> => {
|
||||
const value = await readSettingValue("capacityNotifyThreshold")
|
||||
if (value !== null) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 1) return parsed
|
||||
}
|
||||
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
|
||||
}
|
||||
)
|
||||
|
||||
/** 导出默认值常量(供测试与文档引用) */
|
||||
export const ELECTIVE_DEFAULTS = {
|
||||
MAX_CREDIT_PER_TERM: DEFAULT_MAX_CREDIT_PER_TERM,
|
||||
CAPACITY_NOTIFY_THRESHOLD: DEFAULT_CAPACITY_NOTIFY_THRESHOLD,
|
||||
} as const
|
||||
88
src/modules/elective/data-access-stats.ts
Normal file
88
src/modules/elective/data-access-stats.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { count, eq, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { courseSelections, electiveCourses } from "@/shared/db/schema"
|
||||
|
||||
/**
|
||||
* 选课模块管理员概览统计(P1-13 新增)。
|
||||
* 用于 admin/teacher 列表页顶部展示关键指标。
|
||||
*/
|
||||
|
||||
export interface ElectiveOverviewStats {
|
||||
/** 课程总数 */
|
||||
totalCourses: number
|
||||
/** 总选课人数(已录取 + 候补 + 已选) */
|
||||
totalEnrolled: number
|
||||
/** 平均容量使用率(百分比,0-100) */
|
||||
avgUtilization: number
|
||||
/** 待抽签的课程数(selectionMode=lottery 且 status=open 且存在 selected 记录) */
|
||||
pendingLottery: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取选修课全局概览统计(admin 视角)。
|
||||
*
|
||||
* 实现要点:
|
||||
* - 4 个独立查询合并为 3 个 SQL(pendingLottery 需 join),避免 N+1
|
||||
* - 使用 SQL 聚合而非拉全表后 reduce,避免大数据量内存峰值
|
||||
* - admin 不做 scope 过滤(统计全部课程)
|
||||
*/
|
||||
export const getElectiveOverviewStats = cache(
|
||||
async (): Promise<ElectiveOverviewStats> => {
|
||||
// 并行执行聚合查询
|
||||
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
|
||||
// 1. 课程总数
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(electiveCourses),
|
||||
|
||||
// 2. 总选课人数(活跃选课记录数)
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
sql`${courseSelections.status} IN ('selected', 'enrolled', 'waitlist')`
|
||||
),
|
||||
|
||||
// 3. 平均容量使用率(capacity > 0 时计算 enrolledCount/capacity 平均值)
|
||||
db
|
||||
.select({
|
||||
avg: sql<number>`COALESCE(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN ${electiveCourses.capacity} > 0
|
||||
THEN ${electiveCourses.enrolledCount}::float / ${electiveCourses.capacity}
|
||||
ELSE 0
|
||||
END
|
||||
) * 100,
|
||||
0
|
||||
)`,
|
||||
})
|
||||
.from(electiveCourses),
|
||||
|
||||
// 4. 待抽签课程数:lottery 模式且 status=open 且有 selected 状态的选课记录
|
||||
db
|
||||
.select({ total: sql<number>`count(distinct ${electiveCourses.id})` })
|
||||
.from(electiveCourses)
|
||||
.innerJoin(
|
||||
courseSelections,
|
||||
eq(courseSelections.courseId, electiveCourses.id)
|
||||
)
|
||||
.where(
|
||||
sql`${electiveCourses.selectionMode} = 'lottery'
|
||||
AND ${electiveCourses.status} = 'open'
|
||||
AND ${courseSelections.status} = 'selected'`
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
totalCourses: totalRow[0]?.total ?? 0,
|
||||
totalEnrolled: enrolledRow[0]?.total ?? 0,
|
||||
avgUtilization: Math.round(Number(utilizationRow[0]?.avg ?? 0)),
|
||||
pendingLottery: pendingRow[0]?.total ?? 0,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -62,6 +62,7 @@ export const mapCourseRow = (
|
||||
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
|
||||
selectionStartAt: toIso(r.selectionStartAt),
|
||||
selectionEndAt: toIso(r.selectionEndAt),
|
||||
dropDeadline: toIso(r.dropDeadline),
|
||||
status: r.status,
|
||||
selectionMode: r.selectionMode,
|
||||
credit: String(r.credit),
|
||||
@@ -89,6 +90,7 @@ export const buildCourseSelect = () =>
|
||||
endDate: electiveCourses.endDate,
|
||||
selectionStartAt: electiveCourses.selectionStartAt,
|
||||
selectionEndAt: electiveCourses.selectionEndAt,
|
||||
dropDeadline: electiveCourses.dropDeadline,
|
||||
status: electiveCourses.status,
|
||||
selectionMode: electiveCourses.selectionMode,
|
||||
credit: electiveCourses.credit,
|
||||
@@ -133,51 +135,41 @@ export const getElectiveCourses = cache(
|
||||
async (
|
||||
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
|
||||
): Promise<ElectiveCourseWithDetails[]> => {
|
||||
try {
|
||||
const conditions: SQL[] = []
|
||||
if (params?.status)
|
||||
conditions.push(
|
||||
eq(electiveCourses.status, params.status)
|
||||
)
|
||||
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
|
||||
if (params?.subjectId)
|
||||
conditions.push(eq(electiveCourses.subjectId, params.subjectId))
|
||||
if (params?.teacherId)
|
||||
conditions.push(eq(electiveCourses.teacherId, params.teacherId))
|
||||
if (params?.scope) {
|
||||
const scopeFilter = buildScopeFilter(params.scope, params.currentUserId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const query = buildCourseSelect()
|
||||
const rows = await (conditions.length > 0
|
||||
? query.where(and(...conditions))
|
||||
: query
|
||||
).orderBy(desc(electiveCourses.createdAt))
|
||||
|
||||
if (rows.length === 0) return []
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourses failed:", error)
|
||||
return []
|
||||
const conditions: SQL[] = []
|
||||
if (params?.status)
|
||||
conditions.push(
|
||||
eq(electiveCourses.status, params.status)
|
||||
)
|
||||
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
|
||||
if (params?.subjectId)
|
||||
conditions.push(eq(electiveCourses.subjectId, params.subjectId))
|
||||
if (params?.teacherId)
|
||||
conditions.push(eq(electiveCourses.teacherId, params.teacherId))
|
||||
if (params?.scope) {
|
||||
const scopeFilter = buildScopeFilter(params.scope, params.currentUserId)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
}
|
||||
|
||||
const query = buildCourseSelect()
|
||||
const rows = await (conditions.length > 0
|
||||
? query.where(and(...conditions))
|
||||
: query
|
||||
).orderBy(desc(electiveCourses.createdAt))
|
||||
|
||||
if (rows.length === 0) return []
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
}
|
||||
)
|
||||
|
||||
export const getElectiveCourseById = cache(
|
||||
async (id: string): Promise<ElectiveCourseWithDetails | null> => {
|
||||
try {
|
||||
const [row] = await buildCourseSelect()
|
||||
.where(eq(electiveCourses.id, id))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
const displayMaps = await resolveCourseDisplayNames([row])
|
||||
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourseById failed:", error)
|
||||
return null
|
||||
}
|
||||
const [row] = await buildCourseSelect()
|
||||
.where(eq(electiveCourses.id, id))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
const displayMaps = await resolveCourseDisplayNames([row])
|
||||
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -201,6 +193,8 @@ export async function createElectiveCourse(
|
||||
endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null,
|
||||
selectionStartAt: data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null,
|
||||
selectionEndAt: data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null,
|
||||
// P2-4:退课截止时间
|
||||
dropDeadline: data.dropDeadline ? safeParseDate(data.dropDeadline, "退课截止时间") : null,
|
||||
status: "draft",
|
||||
selectionMode: data.selectionMode,
|
||||
credit: data.credit,
|
||||
@@ -229,6 +223,9 @@ export async function updateElectiveCourse(
|
||||
update.selectionStartAt = data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null
|
||||
if (data.selectionEndAt !== undefined)
|
||||
update.selectionEndAt = data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null
|
||||
// P2-4:退课截止时间
|
||||
if (data.dropDeadline !== undefined)
|
||||
update.dropDeadline = data.dropDeadline ? safeParseDate(data.dropDeadline, "退课截止时间") : 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
|
||||
|
||||
@@ -6,10 +6,15 @@ import { exportToExcel } from "@/shared/lib/excel"
|
||||
|
||||
import { getElectiveCourses } from "./data-access"
|
||||
import { getCourseSelections } from "./data-access-selections"
|
||||
import {
|
||||
ElectiveCourseStatusEnum,
|
||||
} from "./schema"
|
||||
|
||||
/**
|
||||
* 导出选修课课程列表到 Excel
|
||||
* Sheet 1: 课程明细
|
||||
*
|
||||
* 注意:通过 Zod 枚举做类型守卫,避免 `as` 类型断言。
|
||||
*/
|
||||
export async function exportElectiveCoursesToExcel(params: {
|
||||
status?: string
|
||||
@@ -17,8 +22,14 @@ export async function exportElectiveCoursesToExcel(params: {
|
||||
}): Promise<Buffer> {
|
||||
const t = await getTranslations("elective")
|
||||
|
||||
// 用 Zod 枚举校验 status,通过则类型收窄为 ElectiveCourseStatus
|
||||
const statusParsed = params.status
|
||||
? ElectiveCourseStatusEnum.safeParse(params.status)
|
||||
: undefined
|
||||
const status = statusParsed?.success ? statusParsed.data : undefined
|
||||
|
||||
const courses = await getElectiveCourses({
|
||||
status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined,
|
||||
status,
|
||||
teacherId: params.teacherId,
|
||||
})
|
||||
|
||||
@@ -33,7 +44,7 @@ export async function exportElectiveCoursesToExcel(params: {
|
||||
[t("fields.schedule")]: c.schedule ?? "",
|
||||
[t("fields.credit")]: c.credit,
|
||||
[t("fields.selectionMode")]: t(`selectionMode.${c.selectionMode}`),
|
||||
status: t(`status.${c.status}`),
|
||||
[t("export.statusHeader")]: t(`status.${c.status}`),
|
||||
[t("fields.startDate")]: c.startDate ?? "",
|
||||
[t("fields.endDate")]: c.endDate ?? "",
|
||||
}))
|
||||
@@ -41,7 +52,7 @@ export async function exportElectiveCoursesToExcel(params: {
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: t("title.adminList"),
|
||||
name: t("export.courseSheetName"),
|
||||
columns: [
|
||||
{ header: t("fields.name"), key: t("fields.name"), width: 24 },
|
||||
{ header: t("fields.teacher"), key: t("fields.teacher"), width: 16 },
|
||||
@@ -53,7 +64,7 @@ export async function exportElectiveCoursesToExcel(params: {
|
||||
{ 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("export.statusHeader"), key: t("export.statusHeader"), width: 12 },
|
||||
{ header: t("fields.startDate"), key: t("fields.startDate"), width: 14 },
|
||||
{ header: t("fields.endDate"), key: t("fields.endDate"), width: 14 },
|
||||
],
|
||||
@@ -75,25 +86,25 @@ export async function exportCourseSelectionsToExcel(params: {
|
||||
const selections = await getCourseSelections(params.courseId)
|
||||
|
||||
const rows = selections.map((s, idx) => ({
|
||||
"#": idx + 1,
|
||||
[t("export.indexHeader")]: 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] : "",
|
||||
[t("export.statusHeader")]: t(`selectionStatus.${s.status}`),
|
||||
[t("export.priorityHeader")]: s.priority ?? 1,
|
||||
[t("export.selectedAtHeader")]: s.selectedAt.split("T")[0],
|
||||
[t("export.enrolledAtHeader")]: s.enrolledAt ? s.enrolledAt.split("T")[0] : "",
|
||||
}))
|
||||
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: t("student.mySelections"),
|
||||
name: t("export.selectionSheetName"),
|
||||
columns: [
|
||||
{ header: "#", key: "#", width: 6 },
|
||||
{ header: t("export.indexHeader"), key: t("export.indexHeader"), 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 },
|
||||
{ header: t("export.statusHeader"), key: t("export.statusHeader"), width: 12 },
|
||||
{ header: t("export.priorityHeader"), key: t("export.priorityHeader"), width: 10 },
|
||||
{ header: t("export.selectedAtHeader"), key: t("export.selectedAtHeader"), width: 14 },
|
||||
{ header: t("export.enrolledAtHeader"), key: t("export.enrolledAtHeader"), width: 14 },
|
||||
],
|
||||
rows,
|
||||
},
|
||||
|
||||
@@ -63,6 +63,13 @@ export const CreateElectiveCourseSchema = z
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(isValidDateString, "选课结束时间格式无效"),
|
||||
/** 退课截止时间(P2-4 新增):超过此时间学生不可退课 */
|
||||
dropDeadline: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(isValidDateString, "退课截止时间格式无效"),
|
||||
selectionMode: ElectiveSelectionModeEnum.optional(),
|
||||
credit: z.string().trim().optional().nullable(),
|
||||
})
|
||||
@@ -79,6 +86,7 @@ export const CreateElectiveCourseSchema = z
|
||||
endDate: optionalStringToNull(v.endDate),
|
||||
selectionStartAt: optionalStringToNull(v.selectionStartAt),
|
||||
selectionEndAt: optionalStringToNull(v.selectionEndAt),
|
||||
dropDeadline: optionalStringToNull(v.dropDeadline),
|
||||
selectionMode: v.selectionMode ?? "fcfs",
|
||||
credit: v.credit && v.credit.length > 0 ? v.credit : "1.0",
|
||||
}))
|
||||
@@ -119,6 +127,13 @@ export const UpdateElectiveCourseSchema = z
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(isValidDateString, "选课结束时间格式无效"),
|
||||
/** 退课截止时间(P2-4 新增):超过此时间学生不可退课 */
|
||||
dropDeadline: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(isValidDateString, "退课截止时间格式无效"),
|
||||
status: ElectiveCourseStatusEnum.optional(),
|
||||
selectionMode: ElectiveSelectionModeEnum.optional(),
|
||||
credit: z.string().trim().optional().nullable(),
|
||||
@@ -149,6 +164,10 @@ export const UpdateElectiveCourseSchema = z
|
||||
v.selectionEndAt !== undefined
|
||||
? optionalStringToNull(v.selectionEndAt)
|
||||
: undefined,
|
||||
dropDeadline:
|
||||
v.dropDeadline !== undefined
|
||||
? optionalStringToNull(v.dropDeadline)
|
||||
: undefined,
|
||||
credit:
|
||||
v.credit !== undefined
|
||||
? v.credit && v.credit.length > 0
|
||||
@@ -168,6 +187,8 @@ export type SelectCourseInput = z.infer<typeof SelectCourseSchema>
|
||||
|
||||
export const DropCourseSchema = z.object({
|
||||
courseId: z.string().trim().min(1),
|
||||
/** 退课理由(P2-4 新增):可选,最长 255 字符 */
|
||||
dropReason: z.string().trim().max(255).optional(),
|
||||
})
|
||||
|
||||
export type DropCourseInput = z.infer<typeof DropCourseSchema>
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ElectiveCourse {
|
||||
endDate: string | null
|
||||
selectionStartAt: string | null
|
||||
selectionEndAt: string | null
|
||||
/** 退课截止时间(P2-4 新增):ISO 字符串,为 null 表示不限制 */
|
||||
dropDeadline: string | null
|
||||
status: ElectiveCourseStatus
|
||||
selectionMode: ElectiveSelectionMode
|
||||
credit: string
|
||||
@@ -46,6 +48,8 @@ export interface CourseSelection {
|
||||
selectedAt: string
|
||||
enrolledAt: string | null
|
||||
droppedAt: string | null
|
||||
/** 退课理由(P2-4 新增):学生退课时可选填写 */
|
||||
dropReason: string | null
|
||||
lotteryRank: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
Reference in New Issue
Block a user