refactor(attendance,elective): 审计第二轮 — 全量完成 P0/P1 改进项
P0 修复: - 页面层 i18n 全量补齐(admin/teacher/parent/student × attendance/elective) - types.ts 状态标签常量迁移至 constants.ts(i18n key + Badge variant) - 修复 getTranslations 导入路径(next-intl → next-intl/server) P1 改进: - 解耦 parent 模块对 attendance 类型的直接依赖(本地 view-model 类型) - 导出纯函数(computeStats/buildWarnings/buildLotteryRankCase 等) - 统一空状态为 EmptyState 组件 - 清理死代码读 Action(attendance 5 个 + elective 3 个) - 预留监控埋点接口(trackEvent 13 个新事件名) - 补齐骨架屏 loading.tsx(8 个页面) - AlertDialog 替换 window.confirm(student-selection-view) - a11y 改进(aria-label/role/键盘导航) 修复: - AttendanceStatus 从 constants.ts 重导出,消除 types/constants 双源混乱 - buildWarnings 的 Translator 类型改用 ReturnType<typeof useTranslations>
This commit is contained in:
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
|
||||
import {
|
||||
CreateElectiveCourseSchema,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
RunLotterySchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
getElectiveCourses,
|
||||
getElectiveCourseById,
|
||||
createElectiveCourse,
|
||||
updateElectiveCourse,
|
||||
@@ -22,15 +22,6 @@ import {
|
||||
closeSelection,
|
||||
} from "./data-access"
|
||||
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
|
||||
import {
|
||||
getStudentSelections,
|
||||
getAvailableCoursesForStudent,
|
||||
} from "./data-access-selections"
|
||||
import type {
|
||||
ElectiveCourseWithDetails,
|
||||
CourseSelectionWithDetails,
|
||||
GetElectiveCoursesParams,
|
||||
} from "./types"
|
||||
|
||||
const handleError = (e: unknown): ActionState<never> => {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -103,6 +94,13 @@ export async function createElectiveCourseAction(
|
||||
}
|
||||
const id = await createElectiveCourse(parsed.data, ctx.userId)
|
||||
revalidateElectivePaths(id)
|
||||
await trackEvent({
|
||||
event: "elective.course_created",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "elective_course",
|
||||
properties: { capacity: parsed.data.capacity, selectionMode: parsed.data.selectionMode },
|
||||
})
|
||||
return { success: true, message: "Elective course created", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -148,6 +146,12 @@ export async function updateElectiveCourseAction(
|
||||
}
|
||||
await updateElectiveCourse(id, parsed.data)
|
||||
revalidateElectivePaths(id)
|
||||
await trackEvent({
|
||||
event: "elective.course_updated",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Elective course updated", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -169,6 +173,12 @@ export async function deleteElectiveCourseAction(
|
||||
|
||||
await deleteElectiveCourse(id)
|
||||
revalidateElectivePaths()
|
||||
await trackEvent({
|
||||
event: "elective.course_deleted",
|
||||
userId: ctx.userId,
|
||||
targetId: id,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Elective course deleted" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -190,6 +200,12 @@ export async function openSelectionAction(
|
||||
|
||||
await openSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
await trackEvent({
|
||||
event: "elective.selection_opened",
|
||||
userId: ctx.userId,
|
||||
targetId: courseId,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Selection opened" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -211,6 +227,12 @@ export async function closeSelectionAction(
|
||||
|
||||
await closeSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
await trackEvent({
|
||||
event: "elective.selection_closed",
|
||||
userId: ctx.userId,
|
||||
targetId: courseId,
|
||||
targetType: "elective_course",
|
||||
})
|
||||
return { success: true, message: "Selection closed" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -241,6 +263,13 @@ export async function runLotteryAction(
|
||||
|
||||
const result = await runLottery(parsed.data.courseId)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.lottery_completed",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.courseId,
|
||||
targetType: "elective_course",
|
||||
properties: { enrolled: result.enrolled, waitlist: result.waitlist },
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
|
||||
@@ -270,6 +299,13 @@ export async function selectCourseAction(
|
||||
}
|
||||
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.course_selected",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.courseId,
|
||||
targetType: "course_selection",
|
||||
properties: { status: result.status, priority: parsed.data.priority },
|
||||
})
|
||||
return { success: true, message: result.message, data: result.status }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -294,52 +330,14 @@ export async function dropCourseAction(
|
||||
}
|
||||
await dropCourse(parsed.data.courseId, ctx.userId)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
await trackEvent({
|
||||
event: "elective.course_dropped",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.courseId,
|
||||
targetType: "course_selection",
|
||||
})
|
||||
return { success: true, message: "Course dropped" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getElectiveCoursesAction(
|
||||
params?: GetElectiveCoursesParams
|
||||
): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||
const data = await getElectiveCourses({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentSelectionsAction(
|
||||
studentId: string
|
||||
): Promise<ActionState<CourseSelectionWithDetails[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
return { success: false, message: "Can only view your own selections" }
|
||||
}
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
return { success: false, message: "Can only view your children's selections" }
|
||||
}
|
||||
const data = await getStudentSelections(studentId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableCoursesAction(): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
|
||||
const data = await getAvailableCoursesForStudent(ctx.userId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||
import { isSelectionMode } from "../constants"
|
||||
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
@@ -28,9 +29,6 @@ interface Option {
|
||||
name: string
|
||||
}
|
||||
|
||||
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
|
||||
v === "fcfs" || v === "lottery"
|
||||
|
||||
export function ElectiveCourseForm({
|
||||
mode,
|
||||
course,
|
||||
|
||||
@@ -15,10 +15,10 @@ import type { ActionState } from "@/shared/types/action-state"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
ELECTIVE_STATUS_COLORS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
ELECTIVE_STATUS_BADGE_VARIANTS,
|
||||
ELECTIVE_STATUS_LABEL_KEYS,
|
||||
SELECTION_MODE_LABEL_KEYS,
|
||||
} from "../constants"
|
||||
import type { ElectiveCourseWithDetails } from "../types"
|
||||
import {
|
||||
deleteElectiveCourseAction,
|
||||
@@ -59,7 +59,7 @@ export function ElectiveCourseList({
|
||||
toast.success(res.message ?? successMsg)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Operation failed")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -72,10 +72,10 @@ export function ElectiveCourseList({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await deleteElectiveCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "Course deleted")
|
||||
toast.success(res.message ?? t("actions.delete"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Delete failed")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -113,8 +113,8 @@ export function ElectiveCourseList({
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<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_COLORS[course.status]} className="shrink-0">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
|
||||
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
@@ -125,7 +125,7 @@ export function ElectiveCourseList({
|
||||
{course.gradeName ? (
|
||||
<Badge variant="outline">{course.gradeName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
<span>{t("fields.credit")}: {course.credit}</span>
|
||||
</div>
|
||||
|
||||
{course.description ? (
|
||||
@@ -136,25 +136,25 @@ export function ElectiveCourseList({
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Mode:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.selectionMode")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{SELECTION_MODE_LABELS[course.selectionMode]}
|
||||
{t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{course.classroom ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Room:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.classroom")}:</span>{" "}
|
||||
<span className="font-medium">{course.classroom}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -162,7 +162,7 @@ export function ElectiveCourseList({
|
||||
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ElectiveCourseList({
|
||||
>
|
||||
<a href={`${editBaseHref}/${course.id}/edit`}>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -185,10 +185,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
|
||||
onClick={() => runAction(openSelectionAction, course.id, t("actions.openSelection"))}
|
||||
>
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
Open
|
||||
{t("actions.openSelection")}
|
||||
</Button>
|
||||
) : null}
|
||||
{course.status === "open" ? (
|
||||
@@ -196,10 +196,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
|
||||
onClick={() => runAction(closeSelectionAction, course.id, t("actions.closeSelection"))}
|
||||
>
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
Close
|
||||
{t("actions.closeSelection")}
|
||||
</Button>
|
||||
) : null}
|
||||
{course.selectionMode === "lottery" && course.status !== "draft" ? (
|
||||
@@ -207,10 +207,10 @@ export function ElectiveCourseList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPendingThis}
|
||||
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
|
||||
onClick={() => runAction(runLotteryAction, course.id, t("actions.runLottery"))}
|
||||
>
|
||||
<Shuffle className="mr-1 h-3 w-3" />
|
||||
Lottery
|
||||
{t("actions.runLottery")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -221,7 +221,7 @@ export function ElectiveCourseList({
|
||||
onClick={() => handleDelete(course.id)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Delete
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function ElectiveFilters() {
|
||||
const t = useTranslations("elective")
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all"))
|
||||
|
||||
@@ -29,18 +31,18 @@ export function ElectiveFilters() {
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search by course name, teacher..."
|
||||
placeholder={t("form.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Selection Mode" />
|
||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
|
||||
<SelectValue placeholder={t("fields.selectionMode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Modes</SelectItem>
|
||||
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
||||
<SelectItem value="lottery">Lottery</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
|
||||
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,32 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
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_COLORS,
|
||||
COURSE_SELECTION_STATUS_LABELS,
|
||||
ELECTIVE_STATUS_LABELS,
|
||||
SELECTION_MODE_LABELS,
|
||||
} from "../types"
|
||||
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,
|
||||
@@ -30,6 +43,7 @@ export function StudentSelectionView({
|
||||
mySelections: CourseSelectionWithDetails[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
@@ -47,10 +61,10 @@ export function StudentSelectionView({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await selectCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
toast.success(res.message || t("student.selectSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to select course")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -63,10 +77,10 @@ export function StudentSelectionView({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
toast.success(res.message || t("student.dropSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? "Failed to drop course")
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -76,15 +90,15 @@ export function StudentSelectionView({
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">My Selections</h3>
|
||||
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeSelections.length} active
|
||||
{activeSelections.length}
|
||||
</span>
|
||||
</div>
|
||||
{activeSelections.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No selections yet"
|
||||
description="Browse available courses below and select your electives."
|
||||
title={t("list.empty")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -94,33 +108,52 @@ export function StudentSelectionView({
|
||||
<Card key={sel.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{sel.courseName ?? "Unknown course"}
|
||||
{sel.courseName ?? t("errors.notFound")}
|
||||
</CardTitle>
|
||||
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
|
||||
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
|
||||
<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">
|
||||
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||
</p>
|
||||
) : null}
|
||||
{sel.lotteryRank ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lottery rank: #{sel.lotteryRank}
|
||||
#{sel.lotteryRank}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending && pendingId === sel.courseId}
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
Drop
|
||||
</Button>
|
||||
<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>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDrop(sel.courseId)}
|
||||
>
|
||||
{t("actions.drop")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -130,15 +163,15 @@ export function StudentSelectionView({
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Available Courses</h3>
|
||||
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{availableCourses.length} open
|
||||
{availableCourses.length}
|
||||
</span>
|
||||
</div>
|
||||
{availableCourses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No available courses"
|
||||
description="There are no elective courses open for selection right now."
|
||||
title={t("list.emptyStudent")}
|
||||
description={t("description.student")}
|
||||
icon={BookOpen}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -149,11 +182,11 @@ export function StudentSelectionView({
|
||||
const alreadySelected = selectedCourseIds.has(course.id)
|
||||
const isPendingThis = isPending && pendingId === course.id
|
||||
return (
|
||||
<Card key={course.id} className="flex h-full flex-col">
|
||||
<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="outline">
|
||||
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||
<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">
|
||||
@@ -161,8 +194,8 @@ export function StudentSelectionView({
|
||||
{course.subjectName ? (
|
||||
<Badge variant="outline">{course.subjectName}</Badge>
|
||||
) : null}
|
||||
<span>Credit: {course.credit}</span>
|
||||
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
|
||||
<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">
|
||||
@@ -171,27 +204,27 @@ export function StudentSelectionView({
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
|
||||
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{course.enrolledCount}/{course.capacity}
|
||||
{isFull ? " (Full)" : ""}
|
||||
{isFull ? ` (${t("student.capacityFull")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{course.schedule ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||
<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" />
|
||||
Already selected
|
||||
{t("student.selected")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -199,7 +232,7 @@ export function StudentSelectionView({
|
||||
disabled={isPendingThis}
|
||||
onClick={() => handleSelect(course.id)}
|
||||
>
|
||||
{isPendingThis ? "Selecting..." : "Select"}
|
||||
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
55
src/modules/elective/constants.ts
Normal file
55
src/modules/elective/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 选修课模块共享常量(消除 types.ts / elective-course-form.tsx / elective-filters.tsx 重复定义)。
|
||||
*
|
||||
* 注意:标签使用 i18n key,由组件层通过 `useTranslations("elective")` 解析。
|
||||
*/
|
||||
import type {
|
||||
ElectiveCourseStatus,
|
||||
ElectiveSelectionMode,
|
||||
CourseSelectionStatus,
|
||||
} from "./types"
|
||||
|
||||
/** 课程状态 → i18n key 映射 */
|
||||
export const ELECTIVE_STATUS_LABEL_KEYS: Record<ElectiveCourseStatus, string> = {
|
||||
draft: "status.draft",
|
||||
open: "status.open",
|
||||
closed: "status.closed",
|
||||
cancelled: "status.cancelled",
|
||||
}
|
||||
|
||||
/** 课程状态 → Badge variant 映射 */
|
||||
export const ELECTIVE_STATUS_BADGE_VARIANTS: Record<ElectiveCourseStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
closed: "outline",
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
/** 选课模式 → i18n key 映射 */
|
||||
export const SELECTION_MODE_LABEL_KEYS: Record<ElectiveSelectionMode, string> = {
|
||||
fcfs: "selectionMode.fcfs",
|
||||
lottery: "selectionMode.lottery",
|
||||
}
|
||||
|
||||
/** 选课状态 → i18n key 映射 */
|
||||
export const COURSE_SELECTION_STATUS_LABEL_KEYS: Record<CourseSelectionStatus, string> = {
|
||||
selected: "selectionStatus.selected",
|
||||
enrolled: "selectionStatus.enrolled",
|
||||
waitlist: "selectionStatus.waitlist",
|
||||
dropped: "selectionStatus.dropped",
|
||||
rejected: "selectionStatus.rejected",
|
||||
}
|
||||
|
||||
/** 选课状态 → Badge variant 映射 */
|
||||
export const COURSE_SELECTION_STATUS_BADGE_VARIANTS: Record<CourseSelectionStatus, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
selected: "secondary",
|
||||
enrolled: "default",
|
||||
waitlist: "outline",
|
||||
dropped: "destructive",
|
||||
rejected: "destructive",
|
||||
}
|
||||
|
||||
/** 类型守卫:校验字符串是否为合法的选课模式 */
|
||||
export function isSelectionMode(v: string): v is ElectiveSelectionMode {
|
||||
return v === "fcfs" || v === "lottery"
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
|
||||
import type { CourseSelectionStatus } from "./types"
|
||||
|
||||
function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
/**
|
||||
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
|
||||
*/
|
||||
export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
const branches = ids.map(
|
||||
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
|
||||
)
|
||||
|
||||
@@ -66,43 +66,8 @@ export interface GetElectiveCoursesParams {
|
||||
teacherId?: string
|
||||
}
|
||||
|
||||
export const ELECTIVE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
|
||||
draft: "Draft",
|
||||
open: "Open",
|
||||
closed: "Closed",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export const ELECTIVE_STATUS_COLORS: Record<
|
||||
ElectiveCourseStatus,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
closed: "outline",
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
export const SELECTION_MODE_LABELS: Record<ElectiveSelectionMode, string> = {
|
||||
fcfs: "First Come First Served",
|
||||
lottery: "Lottery",
|
||||
}
|
||||
|
||||
export const COURSE_SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
|
||||
selected: "Selected",
|
||||
enrolled: "Enrolled",
|
||||
waitlist: "Waitlist",
|
||||
dropped: "Dropped",
|
||||
rejected: "Rejected",
|
||||
}
|
||||
|
||||
export const COURSE_SELECTION_STATUS_COLORS: Record<
|
||||
CourseSelectionStatus,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
selected: "secondary",
|
||||
enrolled: "default",
|
||||
waitlist: "outline",
|
||||
dropped: "destructive",
|
||||
rejected: "destructive",
|
||||
}
|
||||
/**
|
||||
* 注意:状态标签与颜色映射已迁移至 `./constants.ts`,
|
||||
* 使用 i18n key(`status.*` / `selectionMode.*` / `selectionStatus.*`)+ Badge variant,
|
||||
* 由组件层通过 `useTranslations("elective")` 解析。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user