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:
SpecialX
2026-07-03 10:25:46 +08:00
parent dfffb61e94
commit 138b6f1b00
58 changed files with 3313 additions and 695 deletions

View File

@@ -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
}
/**
* 校验当前用户对课程的管理权限(资源归属校验)。
* - adminscope=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)
}
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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" ? (

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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") {

View File

@@ -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,

View 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. 若未配置或未传入 gradeIdfallback 到 `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.990%)。
*/
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

View 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 个 SQLpendingLottery 需 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,
}
}
)

View File

@@ -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

View File

@@ -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,
},

View File

@@ -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>

View File

@@ -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