refactor(modules): update existing module implementations across attendance, audit, auth, classes, course-plans, exams, files, homework, layout, proctoring, questions, scheduling, textbooks, users

- Update attendance components and data-access for record management

- Update audit log views, filters, and data-access

- Update auth login and register forms

- Update classes actions, components, and data-access (admin, schedule, stats)

- Update course-plans actions, form, list, progress, and schema

- Update exams actions, AI pipeline, preview components, and hooks

- Update files components (icon, list, preview, upload) and data-access

- Update homework assignment form, review view, auto-save hook, and stats-service

- Update layout sidebar, header, and navigation config

- Update proctoring actions, anti-cheat monitor, and data-access

- Update questions actions, components (dialog, actions, columns, filters), and data-access

- Update scheduling actions, auto-scheduler, components, and schema

- Update textbooks constants and text-selection hook

- Update users class-registration, import-dialog, data-access, and user-service
This commit is contained in:
SpecialX
2026-06-23 17:38:56 +08:00
parent 1a9377222c
commit 4f0ef217a0
56 changed files with 1251 additions and 850 deletions

View File

@@ -1,10 +1,11 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import {
createClassScheduleItem,
updateClassScheduleItem,
@@ -50,11 +51,10 @@ export async function createClassScheduleItemAction(
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
return handleActionError(error)
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
return handleActionError(e)
}
}
@@ -93,11 +93,10 @@ export async function updateClassScheduleItemAction(
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
return handleActionError(error)
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
return handleActionError(e)
}
}
@@ -115,10 +114,9 @@ export async function deleteClassScheduleItemAction(scheduleId: string): Promise
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
return handleActionError(error)
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
return handleActionError(e)
}
}

View File

@@ -1,10 +1,11 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import {
createTeacherClass,
deleteTeacherClass,
@@ -68,11 +69,10 @@ export async function createTeacherClassAction(
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
return handleActionError(error)
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
return handleActionError(e)
}
}
@@ -115,11 +115,10 @@ export async function updateTeacherClassAction(
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
return handleActionError(error)
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
return handleActionError(e)
}
}
@@ -139,10 +138,9 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
return handleActionError(error)
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
return handleActionError(e)
}
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
@@ -70,25 +70,31 @@ export function AdminClassesClient({
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
setCreateSchoolId(defaultSchoolId)
setCreateGradeId("")
}, [createOpen, defaultTeacherId, defaultSchoolId])
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
if (createOpen !== prevCreateOpen) {
setPrevCreateOpen(createOpen)
if (createOpen) {
setCreateTeacherId(defaultTeacherId)
setCreateSchoolId(defaultSchoolId)
setCreateGradeId("")
}
}
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditSchoolId(editItem.schoolId ?? "")
setEditGradeId(editItem.gradeId ?? "")
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem])
const [prevEditItem, setPrevEditItem] = useState(editItem)
if (editItem !== prevEditItem) {
setPrevEditItem(editItem)
if (editItem) {
setEditTeacherId(editItem.teacher.id)
setEditSchoolId(editItem.schoolId ?? "")
setEditGradeId(editItem.gradeId ?? "")
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}
}
const handleCreate = async (formData: FormData) => {
setIsWorking(true)

View File

@@ -98,6 +98,8 @@ export function ClassInvitationManager({
} else {
toast.error(result.message ?? t("revokeFailed"))
}
} catch {
toast.error(t("revokeFailed"))
} finally {
setIsSubmitting(false)
}
@@ -258,6 +260,8 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
} else {
toast.error(result.message ?? t("generateFailed"))
}
} catch {
toast.error(t("generateFailed"))
} finally {
setIsSubmitting(false)
}

View File

@@ -23,16 +23,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
@@ -431,25 +422,16 @@ export function GradeClassesClient({
</DialogContent>
</Dialog>
<AlertDialog
<ConfirmDeleteDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title="Delete class"
description={`This will permanently delete ${deleteItem?.name || "this class"}.`}
onConfirm={handleDelete}
isWorking={isWorking}
/>
</>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Plus } from "lucide-react"
@@ -38,12 +38,15 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [createClassId, setCreateClassId] = useState(defaultClassId)
const [weekday, setWeekday] = useState<string>("1")
const [prevOpen, setPrevOpen] = useState(open)
useEffect(() => {
if (!open) return
setCreateClassId(defaultClassId)
setWeekday("1")
}, [open, defaultClassId])
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setCreateClassId(defaultClassId)
setWeekday("1")
}
}
const handleCreate = async (formData: FormData) => {
setIsWorking(true)

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
@@ -74,16 +74,22 @@ export function ScheduleView({
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
useEffect(() => {
if (!editItem) return
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}, [editItem])
const [prevEditItem, setPrevEditItem] = useState(editItem)
if (editItem !== prevEditItem) {
setPrevEditItem(editItem)
if (editItem) {
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}
}
useEffect(() => {
if (!createOpen) return
setCreateClassId(defaultClassId)
}, [createOpen, defaultClassId])
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
if (createOpen !== prevCreateOpen) {
setPrevCreateOpen(createOpen)
if (createOpen) {
setCreateClassId(defaultClassId)
}
}
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
for (const d of WEEKDAYS) byDay.set(d.key, [])

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
@@ -49,10 +49,13 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
useEffect(() => {
if (!open) return
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
}, [open, effectiveClassId, classes])
const [prevOpen, setPrevOpen] = useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
}
}
const handleEnroll = async (formData: FormData) => {
setIsWorking(true)

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, getInitials } from "@/shared/lib/utils"
import { cn, formatDate, getInitials } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,16 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions"
@@ -79,11 +70,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
{s.className}
</span>
<span className="text-[10px]">
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "2-digit"
})}
{formatDate(s.joinedAt, "en-GB")}
</span>
</div>
</div>
@@ -161,41 +148,29 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
))}
</div>
<AlertDialog
<ConfirmDeleteDialog
open={Boolean(removeTarget)}
onOpenChange={(open) => {
if (workingKey !== null) return
if (!open) setRemoveTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove student from class?</AlertDialogTitle>
<AlertDialogDescription>
{removeTarget ? (
<>
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
<span className="font-medium text-foreground">{removeTarget.className}</span>.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={workingKey !== null}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={workingKey !== null}
onClick={() => {
if (!removeTarget) return
setRemoveTarget(null)
setStatus(removeTarget, "inactive")
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title="Remove student from class?"
confirmText="Remove"
description={
removeTarget ? (
<>
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
<span className="font-medium text-foreground">{removeTarget.className}</span>.
</>
) : null
}
onConfirm={() => {
if (!removeTarget) return
setRemoveTarget(null)
setStatus(removeTarget, "inactive")
}}
isWorking={workingKey !== null}
/>
</>
)
}

View File

@@ -402,6 +402,28 @@ export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id:
return rows.map((r) => ({ id: r.id, name: r.name }))
}
/**
* 获取多个年级下的所有班级 ID供 grades 模块 grade_managed scope 过滤使用)。
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
const uniqueIds = Array.from(new Set(gradeIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return []
const rows = await db
.select({ id: classes.id })
.from(classes)
.where(inArray(classes.gradeId, uniqueIds))
return rows.map((r) => r.id)
}
/**
* 构建一个 Drizzle 子查询 SQL用于过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))。
* 供 grades 模块 grade_managed scope 同步构建 SQL 过滤条件使用,避免直接查询 classes 表。
*/
export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => {
return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds))
}
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []