diff --git a/src/modules/attendance/components/attendance-record-list.tsx b/src/modules/attendance/components/attendance-record-list.tsx index 3012e5e..ab17cb0 100644 --- a/src/modules/attendance/components/attendance-record-list.tsx +++ b/src/modules/attendance/components/attendance-record-list.tsx @@ -43,14 +43,19 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ const handleDelete = async () => { if (!deleteId) return setIsDeleting(true) - const result = await deleteAttendanceAction(deleteId) - setIsDeleting(false) - if (result.success) { - toast.success(result.message || t("sheet.deleted")) - setDeleteId(null) - router.refresh() - } else { - toast.error(result.message || t("errors.unexpected")) + try { + const result = await deleteAttendanceAction(deleteId) + if (result.success) { + toast.success(result.message || t("sheet.deleted")) + setDeleteId(null) + router.refresh() + } else { + toast.error(result.message || t("errors.unexpected")) + } + } catch { + toast.error(t("errors.unexpected")) + } finally { + setIsDeleting(false) } } diff --git a/src/modules/attendance/components/attendance-rules-form.tsx b/src/modules/attendance/components/attendance-rules-form.tsx index 7b43015..15a2538 100644 --- a/src/modules/attendance/components/attendance-rules-form.tsx +++ b/src/modules/attendance/components/attendance-rules-form.tsx @@ -72,12 +72,16 @@ export function AttendanceRulesForm({ formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold) formData.set("enableAutoMark", enableAutoMark ? "true" : "false") - const result = await saveAttendanceRulesAction(null, formData) - if (result.success) { - toast.success(result.message || t("rules.saved")) - router.refresh() - } else { - toast.error(result.message || t("errors.unexpected")) + try { + const result = await saveAttendanceRulesAction(null, formData) + if (result.success) { + toast.success(result.message || t("rules.saved")) + router.refresh() + } else { + toast.error(result.message || t("errors.unexpected")) + } + } catch { + toast.error(t("errors.unexpected")) } } diff --git a/src/modules/attendance/components/attendance-sheet.tsx b/src/modules/attendance/components/attendance-sheet.tsx index 877b2be..49f4e48 100644 --- a/src/modules/attendance/components/attendance-sheet.tsx +++ b/src/modules/attendance/components/attendance-sheet.tsx @@ -210,14 +210,19 @@ export function AttendanceSheet({ setIsSubmitting(true) formData.set("recordsJson", JSON.stringify(records)) - const result = await batchRecordAttendanceAction(null, formData) - setIsSubmitting(false) - if (result.success) { - toast.success(result.message || t("sheet.saved")) - router.push("/teacher/attendance") - router.refresh() - } else { - toast.error(result.message || t("errors.unexpected")) + try { + const result = await batchRecordAttendanceAction(null, formData) + if (result.success) { + toast.success(result.message || t("sheet.saved")) + router.push("/teacher/attendance") + router.refresh() + } else { + toast.error(result.message || t("errors.unexpected")) + } + } catch { + toast.error(t("errors.unexpected")) + } finally { + setIsSubmitting(false) } } diff --git a/src/modules/attendance/components/attendance-stats-cards.tsx b/src/modules/attendance/components/attendance-stats-cards.tsx index 84c69d6..f6943b0 100644 --- a/src/modules/attendance/components/attendance-stats-cards.tsx +++ b/src/modules/attendance/components/attendance-stats-cards.tsx @@ -2,6 +2,7 @@ import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-re import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { cn } from "@/shared/lib/utils" interface AttendanceStatsCardsProps { stats: { @@ -68,8 +69,8 @@ export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) { {card.title} -
- +
+
diff --git a/src/modules/attendance/data-access.ts b/src/modules/attendance/data-access.ts index 40876d6..95be488 100644 --- a/src/modules/attendance/data-access.ts +++ b/src/modules/attendance/data-access.ts @@ -11,6 +11,7 @@ import { users, } from "@/shared/db/schema" import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access" +import { safeParseDate } from "@/shared/lib/action-utils" import type { DataScope } from "@/shared/types/permissions" import type { @@ -96,9 +97,9 @@ export async function getAttendanceRecords( } if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId)) if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId)) - if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date))) - if (params.startDate) conditions.push(gte(attendanceRecords.date, new Date(params.startDate))) - if (params.endDate) conditions.push(lte(attendanceRecords.date, new Date(params.endDate))) + if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "日期"))) + if (params.startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(params.startDate, "开始日期"))) + if (params.endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(params.endDate, "结束日期"))) if (params.status) conditions.push(eq(attendanceRecords.status, params.status)) const where = conditions.length > 0 ? and(...conditions) : undefined @@ -163,7 +164,7 @@ export async function createAttendanceRecord( studentId: data.studentId, classId: data.classId, scheduleId: data.scheduleId ?? null, - date: new Date(data.date), + date: safeParseDate(data.date, "日期"), status: data.status, remark: data.remark ?? null, recordedBy, @@ -181,7 +182,7 @@ export async function batchCreateAttendanceRecords( studentId: r.studentId, classId: r.classId, scheduleId: r.scheduleId ?? null, - date: new Date(r.date), + date: safeParseDate(r.date, "日期"), status: r.status, remark: r.remark ?? null, recordedBy, @@ -304,7 +305,7 @@ export async function getAttendanceStats(params: { conditions.push(eq(attendanceRecords.studentId, params.currentUserId)) } if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId)) - if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date))) + if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "日期"))) const where = conditions.length > 0 ? and(...conditions) : undefined diff --git a/src/modules/audit/components/audit-log-table.tsx b/src/modules/audit/components/audit-log-table.tsx index 1e9f467..729c966 100644 --- a/src/modules/audit/components/audit-log-table.tsx +++ b/src/modules/audit/components/audit-log-table.tsx @@ -1,6 +1,6 @@ "use client" -import { Badge, type BadgeProps } from "@/shared/components/ui/badge" +import { Badge } from "@/shared/components/ui/badge" import { Table, TableBody, @@ -9,11 +9,12 @@ import { TableHeader, TableRow, } from "@/shared/components/ui/table" -import { Button } from "@/shared/components/ui/button" -import { ChevronLeft, ChevronRight } from "lucide-react" +import { EmptyTableRow } from "@/shared/components/ui/empty-table-row" +import { Pagination } from "@/shared/components/ui/pagination" +import { StatusBadge } from "@/shared/components/ui/status-badge" import { formatDate } from "@/shared/lib/utils" import type { AuditLog } from "../types" -import { cn } from "@/shared/lib/utils" +import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types" interface AuditLogTableProps { items: AuditLog[] @@ -32,9 +33,6 @@ export function AuditLogTable({ totalPages, onPageChange, }: AuditLogTableProps) { - const start = total === 0 ? 0 : (page - 1) * pageSize + 1 - const end = Math.min(page * pageSize, total) - return (
@@ -52,11 +50,7 @@ export function AuditLogTable({ {items.length === 0 ? ( - - - No audit logs found. - - + ) : ( items.map((log) => ( @@ -85,7 +79,11 @@ export function AuditLogTable({ )} - + {log.ipAddress ?? "-"} @@ -100,57 +98,13 @@ export function AuditLogTable({
-
-
- {total > 0 ? ( - <> - Showing {start}- - {end} of{" "} - {total} logs - - ) : ( - "No logs" - )} -
-
- - Page {page} of {Math.max(totalPages, 1)} - - - -
-
+
) } - -function StatusBadge({ status }: { status: "success" | "failure" }) { - const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive" - return ( - - {status} - - ) -} diff --git a/src/modules/audit/components/data-change-log-table.tsx b/src/modules/audit/components/data-change-log-table.tsx index 9aec12c..775cb6d 100644 --- a/src/modules/audit/components/data-change-log-table.tsx +++ b/src/modules/audit/components/data-change-log-table.tsx @@ -4,7 +4,7 @@ import { useState, Fragment, Suspense } from "react" import { useRouter, useSearchParams } from "next/navigation" import { useQueryState, parseAsString } from "nuqs" import { X } from "lucide-react" -import { Badge, type BadgeProps } from "@/shared/components/ui/badge" +import { Badge } from "@/shared/components/ui/badge" import { Table, TableBody, @@ -15,6 +15,9 @@ import { } from "@/shared/components/ui/table" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" +import { EmptyTableRow } from "@/shared/components/ui/empty-table-row" +import { Pagination } from "@/shared/components/ui/pagination" +import { StatusBadge } from "@/shared/components/ui/status-badge" import { Select, SelectContent, @@ -22,10 +25,12 @@ import { SelectTrigger, SelectValue, } from "@/shared/components/ui/select" -import { ChevronLeft, ChevronRight } from "lucide-react" import { formatDate } from "@/shared/lib/utils" -import { cn } from "@/shared/lib/utils" import type { DataChangeLog, DataChangeStat } from "../types" +import { + DATA_CHANGE_ACTION_VARIANT, + DATA_CHANGE_ACTION_CLASS_NAME, +} from "../types" interface DataChangeLogTableProps { items: DataChangeLog[] @@ -61,9 +66,6 @@ function DataChangeLogTableInner({ router.push(query ? `?${query}` : "?") } - const start = total === 0 ? 0 : (page - 1) * pageSize + 1 - const end = Math.min(page * pageSize, total) - return (
@@ -83,11 +85,7 @@ function DataChangeLogTableInner({ {items.length === 0 ? ( - - - No data change logs found. - - + ) : ( items.map((log) => ( @@ -99,7 +97,11 @@ function DataChangeLogTableInner({ {log.recordId} - +
@@ -154,42 +156,13 @@ function DataChangeLogTableInner({
-
-
- {total > 0 ? ( - <> - Showing {start}- - {end} of{" "} - {total} logs - - ) : ( - "No logs" - )} -
-
- - Page {page} of {Math.max(totalPages, 1)} - - - -
-
+
) } @@ -299,23 +272,6 @@ function DataChangeLogFilters({ ) } -function ActionBadge({ action }: { action: "create" | "update" | "delete" }) { - const variant: BadgeProps["variant"] = - action === "create" ? "default" : action === "update" ? "secondary" : "destructive" - return ( - - {action} - - ) -} - export function DataChangeLogTable(props: DataChangeLogTableProps) { return ( diff --git a/src/modules/audit/components/login-log-table.tsx b/src/modules/audit/components/login-log-table.tsx index 8f7b7a0..1ad8884 100644 --- a/src/modules/audit/components/login-log-table.tsx +++ b/src/modules/audit/components/login-log-table.tsx @@ -1,6 +1,6 @@ "use client" -import { Badge, type BadgeProps } from "@/shared/components/ui/badge" +import { Badge } from "@/shared/components/ui/badge" import { Table, TableBody, @@ -9,11 +9,12 @@ import { TableHeader, TableRow, } from "@/shared/components/ui/table" -import { Button } from "@/shared/components/ui/button" -import { ChevronLeft, ChevronRight } from "lucide-react" +import { EmptyTableRow } from "@/shared/components/ui/empty-table-row" +import { Pagination } from "@/shared/components/ui/pagination" +import { StatusBadge } from "@/shared/components/ui/status-badge" import { formatDate } from "@/shared/lib/utils" import type { LoginLog } from "../types" -import { cn } from "@/shared/lib/utils" +import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types" interface LoginLogTableProps { items: LoginLog[] @@ -32,9 +33,6 @@ export function LoginLogTable({ totalPages, onPageChange, }: LoginLogTableProps) { - const start = total === 0 ? 0 : (page - 1) * pageSize + 1 - const end = Math.min(page * pageSize, total) - return (
@@ -51,11 +49,7 @@ export function LoginLogTable({ {items.length === 0 ? ( - - - No login logs found. - - + ) : ( items.map((log) => ( @@ -73,7 +67,11 @@ export function LoginLogTable({ - + {log.errorMessage && (
{log.errorMessage}
)} @@ -94,57 +92,13 @@ export function LoginLogTable({
-
-
- {total > 0 ? ( - <> - Showing {start}- - {end} of{" "} - {total} logs - - ) : ( - "No logs" - )} -
-
- - Page {page} of {Math.max(totalPages, 1)} - - - -
-
+
) } - -function StatusBadge({ status }: { status: "success" | "failure" }) { - const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive" - return ( - - {status} - - ) -} diff --git a/src/modules/audit/types.ts b/src/modules/audit/types.ts index 7124953..7ebe9f6 100644 --- a/src/modules/audit/types.ts +++ b/src/modules/audit/types.ts @@ -1,3 +1,5 @@ +import type { StatusVariantMap, StatusClassNameMap } from "@/shared/components/ui/status-badge" + export type AuditLogStatus = "success" | "failure" export type LoginLogAction = "signin" | "signout" | "signup" @@ -5,6 +7,30 @@ export type LoginLogStatus = "success" | "failure" export type DataChangeAction = "create" | "update" | "delete" +/** 审计日志/登录日志 success/failure 状态 → Badge variant 映射 */ +export const AUDIT_STATUS_VARIANT: StatusVariantMap = { + success: "default", + failure: "destructive", +} + +/** 审计日志/登录日志 success/failure 状态 → 附加 className 映射 */ +export const AUDIT_STATUS_CLASS_NAME: StatusClassNameMap = { + success: "bg-green-600 hover:bg-green-700 border-transparent", +} + +/** 数据变更 create/update/delete 动作 → Badge variant 映射 */ +export const DATA_CHANGE_ACTION_VARIANT: StatusVariantMap = { + create: "default", + update: "secondary", + delete: "destructive", +} + +/** 数据变更 create/update/delete 动作 → 附加 className 映射 */ +export const DATA_CHANGE_ACTION_CLASS_NAME: StatusClassNameMap = { + create: "bg-green-600 hover:bg-green-700 border-transparent", + delete: "bg-red-600 hover:bg-red-700 border-transparent", +} + export interface AuditLog { id: string userId: string diff --git a/src/modules/auth/components/login-form.tsx b/src/modules/auth/components/login-form.tsx index a9a9c6f..43b7ebb 100644 --- a/src/modules/auth/components/login-form.tsx +++ b/src/modules/auth/components/login-form.tsx @@ -8,18 +8,23 @@ import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { cn } from "@/shared/lib/utils" -import { Loader2, Github } from "lucide-react" +import { Loader2, Github, ShieldCheck } from "lucide-react" +import { preflightTwoFactorAction } from "@/modules/settings/actions-security" type LoginFormProps = React.HTMLAttributes export function LoginForm({ className, ...props }: LoginFormProps) { const [isLoading, setIsLoading] = React.useState(false) + const [requiresTwoFactor, setRequiresTwoFactor] = React.useState(false) + const [totpCode, setTotpCode] = React.useState("") + const [error, setError] = React.useState("") const router = useRouter() const searchParams = useSearchParams() async function onSubmit(event: React.SyntheticEvent) { event.preventDefault() setIsLoading(true) + setError("") const form = event.currentTarget as HTMLFormElement const formData = new FormData(form) @@ -27,10 +32,25 @@ export function LoginForm({ className, ...props }: LoginFormProps) { const password = String(formData.get("password") ?? "") const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard" + // 首次提交:检查是否需要 2FA + if (!requiresTwoFactor) { + try { + const preflight = await preflightTwoFactorAction(email) + if (preflight.required) { + setRequiresTwoFactor(true) + setIsLoading(false) + return + } + } catch { + // 预检失败时静默降级为普通登录 + } + } + const result = await signIn("credentials", { redirect: false, email, password, + totpCode: requiresTwoFactor ? totpCode : undefined, callbackUrl, }) @@ -39,6 +59,13 @@ export function LoginForm({ className, ...props }: LoginFormProps) { if (!result?.error) { router.push(result?.url ?? callbackUrl) router.refresh() + } else { + // 2FA 验证码错误时保留 2FA 输入框,允许用户重新输入 + if (requiresTwoFactor) { + setError("Invalid 2FA code. Please try again.") + } else { + setError("Invalid email or password.") + } } } @@ -49,47 +76,91 @@ export function LoginForm({ className, ...props }: LoginFormProps) { Welcome back

- Enter your email to sign in to your account + {requiresTwoFactor + ? "Enter the 6-digit code from your authenticator app" + : "Enter your email to sign in to your account"}

-
- - -
-
-
- - +
+ + +
+
+
+ + + Forgot password? + +
+ +
+ + ) : ( +
+ + setTotpCode(e.target.value)} + disabled={isLoading} + autoFocus + /> +

+ Enter your 6-digit authenticator code or an 8-character backup code. +

+
- -
+ )} + {error ? ( +

{error}

+ ) : null}
diff --git a/src/modules/classes/actions-schedule.ts b/src/modules/classes/actions-schedule.ts index 2bd2c6c..1afebce 100644 --- a/src/modules/classes/actions-schedule.ts +++ b/src/modules/classes/actions-schedule.ts @@ -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) } } diff --git a/src/modules/classes/actions-teacher.ts b/src/modules/classes/actions-teacher.ts index 0c5a689..4311dc6 100644 --- a/src/modules/classes/actions-teacher.ts +++ b/src/modules/classes/actions-teacher.ts @@ -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 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) diff --git a/src/modules/classes/components/class-invitation-manager.tsx b/src/modules/classes/components/class-invitation-manager.tsx index 33ade19..bec2f47 100644 --- a/src/modules/classes/components/class-invitation-manager.tsx +++ b/src/modules/classes/components/class-invitation-manager.tsx @@ -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) } diff --git a/src/modules/classes/components/grade-classes-view.tsx b/src/modules/classes/components/grade-classes-view.tsx index 7c381e8..66cf970 100644 --- a/src/modules/classes/components/grade-classes-view.tsx +++ b/src/modules/classes/components/grade-classes-view.tsx @@ -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({ - { if (!open) setDeleteItem(null) }} - > - - - Delete class - This will permanently delete {deleteItem?.name || "this class"}. - - - Cancel - - Delete - - - - + title="Delete class" + description={`This will permanently delete ${deleteItem?.name || "this class"}.`} + onConfirm={handleDelete} + isWorking={isWorking} + /> ) } \ No newline at end of file diff --git a/src/modules/classes/components/schedule-filters.tsx b/src/modules/classes/components/schedule-filters.tsx index 9ad5044..eca9134 100644 --- a/src/modules/classes/components/schedule-filters.tsx +++ b/src/modules/classes/components/schedule-filters.tsx @@ -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("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) diff --git a/src/modules/classes/components/schedule-view.tsx b/src/modules/classes/components/schedule-view.tsx index f832dbd..cb5c65b 100644 --- a/src/modules/classes/components/schedule-view.tsx +++ b/src/modules/classes/components/schedule-view.tsx @@ -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() for (const d of WEEKDAYS) byDay.set(d.key, []) diff --git a/src/modules/classes/components/students-filters.tsx b/src/modules/classes/components/students-filters.tsx index 4e45a1e..f2138e1 100644 --- a/src/modules/classes/components/students-filters.tsx +++ b/src/modules/classes/components/students-filters.tsx @@ -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) diff --git a/src/modules/classes/components/students-table.tsx b/src/modules/classes/components/students-table.tsx index 70a4c52..82292bd 100644 --- a/src/modules/classes/components/students-table.tsx +++ b/src/modules/classes/components/students-table.tsx @@ -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} - {new Date(s.joinedAt).toLocaleDateString("en-GB", { - day: "2-digit", - month: "2-digit", - year: "2-digit" - })} + {formatDate(s.joinedAt, "en-GB")}
@@ -161,41 +148,29 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) { ))} - { if (workingKey !== null) return if (!open) setRemoveTarget(null) }} - > - - - Remove student from class? - - {removeTarget ? ( - <> - This will set {removeTarget.name} to inactive in{" "} - {removeTarget.className}. - - ) : null} - - - - Cancel - { - if (!removeTarget) return - setRemoveTarget(null) - setStatus(removeTarget, "inactive") - }} - > - Remove - - - - + title="Remove student from class?" + confirmText="Remove" + description={ + removeTarget ? ( + <> + This will set {removeTarget.name} to inactive in{" "} + {removeTarget.className}. + + ) : null + } + onConfirm={() => { + if (!removeTarget) return + setRemoveTarget(null) + setStatus(removeTarget, "inactive") + }} + isWorking={workingKey !== null} + /> ) } diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts index e1e4a81..ba97083 100644 --- a/src/modules/classes/data-access.ts +++ b/src/modules/classes/data-access.ts @@ -402,6 +402,28 @@ export const getClassesByGradeId = async (gradeId: string): Promise ({ id: r.id, name: r.name })) } +/** + * 获取多个年级下的所有班级 ID(供 grades 模块 grade_managed scope 过滤使用)。 + * 供跨模块调用使用,避免直接查询 classes 表。 + */ +export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise => { + 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 => { const teacherId = params?.teacherId ?? (await getSessionTeacherId()) if (!teacherId) return [] diff --git a/src/modules/course-plans/actions.ts b/src/modules/course-plans/actions.ts index 81ab122..1daa6f1 100644 --- a/src/modules/course-plans/actions.ts +++ b/src/modules/course-plans/actions.ts @@ -1,9 +1,10 @@ "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 { CreateCoursePlanSchema, @@ -23,12 +24,6 @@ import { } from "./data-access" import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem } from "./types" -const handleError = (e: unknown): ActionState => { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } -} - const revalidatePlanPaths = (id?: string) => { revalidatePath("/admin/course-plans") revalidatePath("/teacher/course-plans") @@ -72,7 +67,7 @@ export async function createCoursePlanAction( revalidatePlanPaths(id) return { success: true, message: "Course plan created", data: id } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -115,7 +110,7 @@ export async function updateCoursePlanAction( revalidatePlanPaths(id) return { success: true, message: "Course plan updated", data: id } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -132,7 +127,7 @@ export async function deleteCoursePlanAction( revalidatePlanPaths() return { success: true, message: "Course plan deleted" } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -144,7 +139,7 @@ export async function getCoursePlansAction( const data = await getCoursePlans(params) return { success: true, data } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -157,7 +152,7 @@ export async function getCoursePlanAction( if (!data) return { success: false, message: "Course plan not found" } return { success: true, data } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -190,7 +185,7 @@ export async function createCoursePlanItemAction( revalidatePlanPaths(parsed.data.planId) return { success: true, message: "Week plan added", data: itemId } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -230,7 +225,7 @@ export async function updateCoursePlanItemAction( revalidatePlanPaths() return { success: true, message: "Week plan updated", data: id } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -243,7 +238,7 @@ export async function deleteCoursePlanItemAction( revalidatePlanPaths() return { success: true, message: "Week plan deleted" } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -263,6 +258,6 @@ export async function toggleCoursePlanItemCompletedAction( message: completed ? "Marked as completed" : "Marked as incomplete", } } catch (e) { - return handleError(e) + return handleActionError(e) } } diff --git a/src/modules/course-plans/data-access.ts b/src/modules/course-plans/data-access.ts index 878cfe5..0b22ea1 100644 --- a/src/modules/course-plans/data-access.ts +++ b/src/modules/course-plans/data-access.ts @@ -12,6 +12,7 @@ import { subjects, users, } from "@/shared/db/schema" +import { safeParseDate } from "@/shared/lib/action-utils" import type { CoursePlan, CoursePlanItem, @@ -203,8 +204,8 @@ export async function createCoursePlan( totalHours: data.totalHours, completedHours: 0, weeklyHours: data.weeklyHours, - startDate: data.startDate ? new Date(data.startDate) : null, - endDate: data.endDate ? new Date(data.endDate) : null, + startDate: data.startDate ? safeParseDate(data.startDate, "开始日期") : null, + endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null, syllabus: data.syllabus, objectives: data.objectives, status: data.status, @@ -227,9 +228,9 @@ export async function updateCoursePlan( if (data.completedHours !== undefined) update.completedHours = data.completedHours if (data.weeklyHours !== undefined) update.weeklyHours = data.weeklyHours if (data.startDate !== undefined) - update.startDate = data.startDate ? new Date(data.startDate) : null + update.startDate = data.startDate ? safeParseDate(data.startDate, "开始日期") : null if (data.endDate !== undefined) - update.endDate = data.endDate ? new Date(data.endDate) : null + update.endDate = data.endDate ? safeParseDate(data.endDate, "结束日期") : null if (data.syllabus !== undefined) update.syllabus = data.syllabus if (data.objectives !== undefined) update.objectives = data.objectives if (data.status !== undefined) update.status = data.status @@ -273,7 +274,7 @@ export async function updateCoursePlanItem( if (data.notes !== undefined) update.notes = data.notes if (data.isCompleted !== undefined) update.isCompleted = data.isCompleted if (data.completedAt !== undefined) - update.completedAt = data.completedAt ? new Date(data.completedAt) : null + update.completedAt = data.completedAt ? safeParseDate(data.completedAt, "完成日期") : null if (Object.keys(update).length === 0) return diff --git a/src/modules/course-plans/schema.ts b/src/modules/course-plans/schema.ts index 11d4654..5b60e6a 100644 --- a/src/modules/course-plans/schema.ts +++ b/src/modules/course-plans/schema.ts @@ -1,5 +1,11 @@ import { z } from "zod" +const isValidDateString = (v: string | null | undefined): boolean => { + if (v === null || v === undefined || v === "") return true + const d = new Date(v) + return !Number.isNaN(d.getTime()) +} + export const CreateCoursePlanSchema = z .object({ classId: z.string().trim().min(1), @@ -9,8 +15,18 @@ export const CreateCoursePlanSchema = z semester: z.enum(["1", "2"]).optional(), totalHours: z.coerce.number().int().min(0).optional(), weeklyHours: z.coerce.number().int().min(0).optional(), - startDate: z.string().trim().optional().nullable(), - endDate: z.string().trim().optional().nullable(), + startDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "开始日期格式无效"), + endDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "结束日期格式无效"), syllabus: z.string().trim().optional().nullable(), objectives: z.string().trim().optional().nullable(), status: z.enum(["planning", "active", "completed", "paused"]).optional(), @@ -42,8 +58,18 @@ export const UpdateCoursePlanSchema = z totalHours: z.coerce.number().int().min(0).optional(), completedHours: z.coerce.number().int().min(0).optional(), weeklyHours: z.coerce.number().int().min(0).optional(), - startDate: z.string().trim().optional().nullable(), - endDate: z.string().trim().optional().nullable(), + startDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "开始日期格式无效"), + endDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "结束日期格式无效"), syllabus: z.string().trim().optional().nullable(), objectives: z.string().trim().optional().nullable(), status: z.enum(["planning", "active", "completed", "paused"]).optional(), @@ -116,7 +142,12 @@ export const UpdateCoursePlanItemSchema = z textbookChapter: z.string().trim().optional().nullable(), notes: z.string().trim().optional().nullable(), isCompleted: z.boolean().optional(), - completedAt: z.string().trim().optional().nullable(), + completedAt: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "完成日期格式无效"), }) .transform((v) => ({ ...v, diff --git a/src/modules/elective/schema.ts b/src/modules/elective/schema.ts index b260709..bf39bdb 100644 --- a/src/modules/elective/schema.ts +++ b/src/modules/elective/schema.ts @@ -17,6 +17,12 @@ export const CourseSelectionStatusEnum = z.enum([ "rejected", ]) +const isValidDateString = (v: string | null | undefined): boolean => { + if (v === null || v === undefined || v === "") return true + const d = new Date(v) + return !Number.isNaN(d.getTime()) +} + const emptyToNull = (v: string | undefined | null): string | null => v && v.length > 0 ? v : null @@ -33,10 +39,30 @@ export const CreateElectiveCourseSchema = z capacity: z.coerce.number().int().min(1).max(500).optional(), classroom: z.string().trim().optional().nullable(), schedule: z.string().trim().optional().nullable(), - startDate: z.string().trim().optional().nullable(), - endDate: z.string().trim().optional().nullable(), - selectionStartAt: z.string().trim().optional().nullable(), - selectionEndAt: z.string().trim().optional().nullable(), + startDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "开始日期格式无效"), + endDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "结束日期格式无效"), + selectionStartAt: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "选课开始时间格式无效"), + selectionEndAt: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "选课结束时间格式无效"), selectionMode: ElectiveSelectionModeEnum.optional(), credit: z.string().trim().optional().nullable(), }) @@ -69,10 +95,30 @@ export const UpdateElectiveCourseSchema = z capacity: z.coerce.number().int().min(1).max(500).optional(), classroom: z.string().trim().optional().nullable(), schedule: z.string().trim().optional().nullable(), - startDate: z.string().trim().optional().nullable(), - endDate: z.string().trim().optional().nullable(), - selectionStartAt: z.string().trim().optional().nullable(), - selectionEndAt: z.string().trim().optional().nullable(), + startDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "开始日期格式无效"), + endDate: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "结束日期格式无效"), + selectionStartAt: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "选课开始时间格式无效"), + selectionEndAt: z + .string() + .trim() + .optional() + .nullable() + .refine(isValidDateString, "选课结束时间格式无效"), status: ElectiveCourseStatusEnum.optional(), selectionMode: ElectiveSelectionModeEnum.optional(), credit: z.string().trim().optional().nullable(), diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index ffd06ba..b212168 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -6,6 +6,11 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar import { Permissions } from "@/shared/types/permissions" import { z } from "zod" import { createId } from "@paralleldrive/cuid2" +import { + handleActionError, + safeJsonParse, +} from "@/shared/lib/action-utils" +import { trackExamEvent } from "@/shared/lib/track-event" import { buildExamDescription, deleteExamById, @@ -73,12 +78,15 @@ const parseExamModeConfig = (formData: FormData): ExamModeConfig => { const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration)) ? Number(rawDuration) : null + const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0" + const parsedGrace = Number(rawGrace) + const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0 return { examMode, durationMinutes, shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false), allowLateStart: getBoolValue(formData, "allowLateStart", false), - lateStartGraceMinutes: Number(getStringValue(formData, "lateStartGraceMinutes") ?? "0") || 0, + lateStartGraceMinutes, antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false), } } @@ -315,7 +323,7 @@ export async function createExamAction( totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, - questions: rawQuestions ? JSON.parse(rawQuestions) : [], + questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [], }) if (!parsed.success) { @@ -345,7 +353,7 @@ export async function createExamAction( examModeConfig: parseExamModeConfig(formData), }) } catch (error) { - console.error("Failed to create exam:", error) + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to create exam") } @@ -356,7 +364,7 @@ export async function createExamAction( if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -403,7 +411,7 @@ export async function createAiExamAction( totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, - questions: rawQuestions ? JSON.parse(rawQuestions) : [], + questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [], aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined, aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0 ? aiQuestionCountRaw @@ -465,18 +473,28 @@ export async function createAiExamAction( examModeConfig: parseExamModeConfig(formData), }) } catch (error) { - console.error("Failed to create exam:", error) + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to create exam") } revalidatePath("/teacher/exams/all") + // V3-4: 埋点监控(AI 生成考试) + await trackExamEvent("exam.ai_generated", { + userId: ctx.userId, + targetId: context.examId, + properties: { + aiSourceText: input.aiSourceText?.length ?? 0, + aiQuestionCount: input.aiQuestionCount, + }, + }) + return successState(context.examId, "Exam created successfully.") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -529,7 +547,7 @@ export async function previewAiExamAction( if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -565,14 +583,15 @@ export async function regenerateAiQuestionAction( score: result.data.score ?? originalScore, content: result.data.content, }) - } catch { + } catch (error) { + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("AI question format invalid") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -599,13 +618,13 @@ export async function updateExamAction( const rawQuestions = formData.get("questionsJson") const rawStructure = formData.get("structureJson") - const hasQuestions = typeof rawQuestions === "string" - const hasStructure = typeof rawStructure === "string" + const rawQuestionsStr = typeof rawQuestions === "string" ? rawQuestions : null + const rawStructureStr = typeof rawStructure === "string" ? rawStructure : null const parsed = ExamUpdateSchema.safeParse({ examId: formData.get("examId"), - questions: hasQuestions ? JSON.parse(rawQuestions) : undefined, - structure: hasStructure ? JSON.parse(rawStructure) : undefined, + questions: rawQuestionsStr ? safeJsonParse(rawQuestionsStr, "题目数据格式无效") : undefined, + structure: rawStructureStr ? safeJsonParse(rawStructureStr, "试卷结构数据格式无效") : undefined, status: formData.get("status") ?? undefined, }) @@ -632,18 +651,26 @@ export async function updateExamAction( structure, status, }) - } catch { + } catch (error) { + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to update exam") } revalidatePath("/teacher/exams/all") + // V3-4: 埋点监控 + await trackExamEvent("exam.updated", { + userId: ctx.userId, + targetId: examId, + properties: { hasQuestions: !!questions, hasStructure: !!structure, status }, + }) + return successState(examId, "Exam updated") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -681,18 +708,25 @@ export async function deleteExamAction( try { await deleteExamById(examId) - } catch { + } catch (error) { + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to delete exam") } revalidatePath("/teacher/exams/all") + // V3-4: 埋点监控 + await trackExamEvent("exam.deleted", { + userId: ctx.userId, + targetId: examId, + }) + return successState(examId, "Exam deleted") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -727,18 +761,26 @@ export async function duplicateExamAction( return failState("Exam not found") } newExamId = duplicatedId - } catch { + } catch (error) { + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to duplicate exam") } revalidatePath("/teacher/exams/all") + // V3-4: 埋点监控 + await trackExamEvent("exam.duplicated", { + userId: ctx.userId, + targetId: newExamId, + properties: { sourceExamId: examId }, + }) + return successState(newExamId, "Exam duplicated") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } - throw error + return handleActionError(error) } } @@ -759,14 +801,14 @@ export async function getExamPreviewAction( questions: exam.questions, }) } catch (error) { - console.error(error) + console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message) } - throw error + return handleActionError(error) } } @@ -778,14 +820,14 @@ export async function getSubjectsAction(): Promise("Failed to load subjects") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } - throw error + return handleActionError(error) } } @@ -797,14 +839,14 @@ export async function getGradesAction(): Promise("Failed to load grades") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } - throw error + return handleActionError(error) } } diff --git a/src/modules/exams/ai-pipeline/structure.ts b/src/modules/exams/ai-pipeline/structure.ts index 2c1ac37..45a8d14 100644 --- a/src/modules/exams/ai-pipeline/structure.ts +++ b/src/modules/exams/ai-pipeline/structure.ts @@ -73,7 +73,13 @@ export const mapWithConcurrency = async ( while (cursor < items.length) { const index = cursor cursor += 1 - results[index] = await worker(items[index], index) + try { + results[index] = await worker(items[index], index) + } catch (error) { + // Catch per-item errors so a single failure doesn't reject the whole batch. + // The result slot stays undefined; callers should handle missing entries. + console.error("[mapWithConcurrency] worker error at index", index, error instanceof Error ? error.message : String(error)) + } } } const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker()) diff --git a/src/modules/exams/components/assembly/exam-paper-preview.tsx b/src/modules/exams/components/assembly/exam-paper-preview.tsx index d93a9c8..ba4d55c 100644 --- a/src/modules/exams/components/assembly/exam-paper-preview.tsx +++ b/src/modules/exams/components/assembly/exam-paper-preview.tsx @@ -1,5 +1,6 @@ "use client" +import { useMemo } from "react" import type { ExamNode } from "./selected-question-list" type ChoiceOption = { @@ -21,23 +22,41 @@ type ExamPaperPreviewProps = { nodes: ExamNode[] } -export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) { - // Helper to flatten questions for continuous numbering - let questionCounter = 0 +const parseContent = (raw: unknown): QuestionContent => { + if (raw && typeof raw === "object") return raw as QuestionContent + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === "object") return parsed as QuestionContent + return { text: raw } + } catch { + return { text: raw } + } + } + return {} +} - const parseContent = (raw: unknown): QuestionContent => { - if (raw && typeof raw === "object") return raw as QuestionContent - if (typeof raw === "string") { - try { - const parsed = JSON.parse(raw) as unknown - if (parsed && typeof parsed === "object") return parsed as QuestionContent - return { text: raw } - } catch { - return { text: raw } +// Precompute question numbers as a Map to avoid mutating a counter during render +const buildQuestionNumberMap = (nodes: ExamNode[]): Map => { + const map = new Map() + let counter = 0 + const walk = (list: ExamNode[]) => { + for (const node of list) { + if (node.type === "question" && node.question) { + counter += 1 + map.set(node.id, counter) + } else if (node.type === "group" && node.children) { + walk(node.children) } } - return {} } + walk(nodes) + return map +} + +export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) { + // Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment. + const numberMap = useMemo(() => buildQuestionNumberMap(nodes), [nodes]) const renderNode = (node: ExamNode, depth: number = 0) => { if (node.type === 'group') { @@ -57,20 +76,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor } if (node.type === 'question' && node.question) { - questionCounter++ + const questionNumber = numberMap.get(node.id) ?? 0 const q = node.question const content = parseContent(q.content) - + return (
- {questionCounter}. + {questionNumber}.
- {content.text ?? ""} + {content.text ?? ""} ({node.score}分)
- + {/* Options for Choice Questions */} {(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
diff --git a/src/modules/exams/components/assembly/selected-question-list.tsx b/src/modules/exams/components/assembly/selected-question-list.tsx index 18111e9..2748dc0 100644 --- a/src/modules/exams/components/assembly/selected-question-list.tsx +++ b/src/modules/exams/components/assembly/selected-question-list.tsx @@ -68,7 +68,7 @@ export function SelectedQuestionList({
- {node.children?.length === 0 ? ( + {!node.children || node.children.length === 0 ? (
Drag questions here or add from bank
) : ( node.children?.map((child, cIdx) => ( @@ -191,13 +191,13 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: { - onScoreChange(parseInt(e.target.value) || 0)} + onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)} />
diff --git a/src/modules/exams/components/assembly/structure-editor.tsx b/src/modules/exams/components/assembly/structure-editor.tsx index 90ce293..aba2fd6 100644 --- a/src/modules/exams/components/assembly/structure-editor.tsx +++ b/src/modules/exams/components/assembly/structure-editor.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useMemo, useState } from "react" +import React, { useCallback, useMemo, useState } from "react" import { DndContext, pointerWithin, @@ -54,6 +54,30 @@ function cloneExamNodes(nodes: ExamNode[]): ExamNode[] { }) } +// Safely extract a text preview from a question's content (which may be a string, +// object, or JSON string). Avoids `as` assertions by runtime narrowing. +const extractQuestionText = (raw: unknown): string => { + if (!raw) return "" + if (typeof raw === "string") { + // Content might be a JSON string or plain text + try { + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === "object") { + const obj = parsed as Record + return typeof obj.text === "string" ? obj.text : "" + } + return raw + } catch { + return raw + } + } + if (typeof raw === "object") { + const obj = raw as Record + return typeof obj.text === "string" ? obj.text : "" + } + return "" +} + // --- Components --- function SortableItem({ @@ -135,13 +159,13 @@ function SortableItem({ - onScoreChange(parseInt(e.target.value) || 0)} + onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)} />
@@ -179,6 +203,7 @@ function SortableGroup({ opacity: isDragging ? 0.5 : 1, } + const childrenKey = JSON.stringify(item.children || []) const totalScore = useMemo(() => { const calc = (nodes: ExamNode[]): number => { return nodes.reduce((acc, node) => { @@ -188,7 +213,8 @@ function SortableGroup({ }, 0) } return calc(item.children || []) - }, [item]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [childrenKey]) return ( @@ -227,13 +253,14 @@ function SortableGroup({ ) } -function StructureRenderer({ nodes, ...props }: { - nodes: ExamNode[] +function StructureRenderer({ nodes, ...props }: { + nodes: ExamNode[] onRemove: (id: string) => void onScoreChange: (id: string, score: number) => void onGroupTitleChange: (id: string, title: string) => void }) { // Deduplicate nodes to prevent React key errors + const nodesKey = JSON.stringify(nodes.map(n => n.id)) const uniqueNodes = useMemo(() => { const seen = new Set() return nodes.filter(n => { @@ -241,7 +268,8 @@ function StructureRenderer({ nodes, ...props }: { seen.add(n.id) return true }) - }, [nodes]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesKey]) return ( n.id)} strategy={verticalListSortingStrategy}> @@ -294,7 +322,7 @@ const dropAnimation: DropAnimation = { export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) { const [activeId, setActiveId] = useState(null) - + const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -303,30 +331,33 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh ) // Recursively find item - const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => { - for (const node of nodes) { - if (node.id === id) return node - if (node.children) { - const found = findItem(id, node.children) - if (found) return found + const findItem = useCallback((id: string, nodes: ExamNode[] = items): ExamNode | null => { + const walk = (list: ExamNode[]): ExamNode | null => { + for (const node of list) { + if (node.id === id) return node + if (node.children) { + const found = walk(node.children) + if (found) return found + } } + return null } - return null - } + return walk(nodes) + }, [items]) const activeItem = activeId ? findItem(activeId) : null // DND Handlers - function handleDragStart(event: DragStartEvent) { + const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(event.active.id as string) - } + }, []) // Custom collision detection for nested sortables - const customCollisionDetection: CollisionDetection = (args) => { + const customCollisionDetection: CollisionDetection = useCallback((args) => { // 1. First check pointer within for precise container detection const pointerCollisions = pointerWithin(args) - + // If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container) if (pointerCollisions.length > 0) { return pointerCollisions @@ -334,7 +365,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh // 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container return rectIntersection(args) - } + }, []) function handleDragOver(event: DragOverEvent) { const { active, over } = event @@ -557,15 +588,15 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { const moved = arrayMove(list, oldIndex, newIndex) - + // Update the list reference in parent if (activeContainerId === 'root') { onChange(moved) - } else { + } else if (activeContainerId) { // list is already a reference to children array if we did it right? // getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array. // So we need to re-assign. - const group = findItem(activeContainerId!, newItems) + const group = findItem(activeContainerId, newItems) if (group) group.children = moved onChange(newItems) } @@ -611,7 +642,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh

- {(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"} + {extractQuestionText(activeItem.question?.content) || "Question"}

) diff --git a/src/modules/exams/components/exam-preview-dialog.tsx b/src/modules/exams/components/exam-preview-dialog.tsx index 1d52189..bf629a3 100644 --- a/src/modules/exams/components/exam-preview-dialog.tsx +++ b/src/modules/exams/components/exam-preview-dialog.tsx @@ -1,6 +1,6 @@ "use client" -import type { ReactNode } from "react" +import { useMemo, type ReactNode } from "react" import { cn } from "@/shared/lib/utils" import { Button } from "@/shared/components/ui/button" import { ScrollArea } from "@/shared/components/ui/scroll-area" @@ -37,6 +37,24 @@ type ExamPreviewDialogProps = { previewTitleValue?: string } +// Precompute question numbers as a Map to avoid mutating a counter during render +const buildQuestionNumberMap = (nodes: ExamNode[]): Map => { + const map = new Map() + let counter = 0 + const walk = (list: ExamNode[]) => { + for (const node of list) { + if (node.type === "question" && node.question && node.questionId) { + counter += 1 + map.set(node.id, counter) + } else if (node.type === "group" && node.children) { + walk(node.children) + } + } + } + walk(nodes) + return map +} + export function ExamPreviewDialog({ previewOpen, setPreviewOpen, @@ -59,8 +77,10 @@ export function ExamPreviewDialog({ handleConfirmCreate, previewTitleValue, }: ExamPreviewDialogProps) { + // Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment. + const numberMap = useMemo(() => buildQuestionNumberMap(previewNodes), [previewNodes]) + const renderSelectablePreview = (nodes: ExamNode[]) => { - let questionCounter = 0 const renderNode = (node: ExamNode, depth: number = 0): ReactNode => { if (node.type === "group") { return ( @@ -75,7 +95,7 @@ export function ExamPreviewDialog({ ) } if (node.type === "question" && node.question && node.questionId) { - questionCounter += 1 + const questionNumber = numberMap.get(node.id) ?? 0 const content = parseEditableContent(node.question.content) const active = node.questionId === selectedQuestionId return ( @@ -89,7 +109,7 @@ export function ExamPreviewDialog({ )} >
- {questionCounter}. + {questionNumber}.
{content.text || "未命名题目"} diff --git a/src/modules/exams/components/exam-preview-question-editor.tsx b/src/modules/exams/components/exam-preview-question-editor.tsx index 02bc819..7b35a29 100644 --- a/src/modules/exams/components/exam-preview-question-editor.tsx +++ b/src/modules/exams/components/exam-preview-question-editor.tsx @@ -13,11 +13,23 @@ import { SelectValue, } from "@/shared/components/ui/select" import type { ExamNode } from "./assembly/selected-question-list" -import type { Question } from "@/modules/questions/types" +import type { QuestionType } from "@/modules/questions/types" import type { EditableQuestionContent } from "./exam-form-types" import { QuestionOptionsEditor } from "./question-options-editor" import { QuestionSubQuestionsEditor } from "./question-sub-questions-editor" +const QUESTION_TYPES: readonly QuestionType[] = [ + "single_choice", + "multiple_choice", + "text", + "judgment", + "composite", +] as const + +function isQuestionType(value: string): value is QuestionType { + return (QUESTION_TYPES as readonly string[]).includes(value) +} + type ExamPreviewQuestionEditorProps = { selectedQuestion: ExamNode | null selectedContent: EditableQuestionContent | null @@ -67,7 +79,9 @@ export function ExamPreviewQuestionEditor({ onValueChange={(value) => { updatePreviewQuestionNode(selectedQuestionId, (node) => { if (!node.question) return node - return { ...node, question: { ...node.question, type: value as Question["type"] } } + // Use type guard to narrow string to Question["type"] instead of `as` assertion + if (!isQuestionType(value)) return node + return { ...node, question: { ...node.question, type: value } } }) }} > diff --git a/src/modules/exams/data-access.ts b/src/modules/exams/data-access.ts index 4cb13a3..2dd3f6c 100644 --- a/src/modules/exams/data-access.ts +++ b/src/modules/exams/data-access.ts @@ -6,6 +6,7 @@ import { createId } from "@paralleldrive/cuid2" import { createQuestionWithRelations } from "@/modules/questions/data-access" import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access" import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/data-access" +import { escapeLikePattern } from "@/shared/lib/action-utils" import type { Exam, ExamDifficulty, ExamStatus } from "./types" import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline" @@ -64,7 +65,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope const conditions = [] if (params.q) { - const search = `%${params.q}%` + const search = `%${escapeLikePattern(params.q)}%` conditions.push(or(like(exams.title, search), like(exams.description, search))) } @@ -82,10 +83,19 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope const gradeIds = Array.from(new Set(classGradeMap.values())) if (gradeIds.length > 0) { conditions.push(inArray(exams.gradeId, gradeIds)) + } else { + // P0 fix: empty grade set must NOT bypass filtering (would expose all exams) + conditions.push(eq(exams.id, "__none__")) } + } else if (params.scope.type === "class_taught") { + // P0 fix: class_taught scope with no classIds must return nothing + conditions.push(eq(exams.id, "__none__")) } if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) { conditions.push(inArray(exams.gradeId, params.scope.gradeIds)) + } else if (params.scope.type === "grade_managed") { + // P0 fix: grade_managed scope with no gradeIds must return nothing + conditions.push(eq(exams.id, "__none__")) } // "all" type: no filtering // "class_members": student sees published exams for their grade (would need student's gradeId) @@ -126,8 +136,10 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) if (params.difficulty && params.difficulty !== "all") { - const d = parseInt(params.difficulty) - result = result.filter((e) => e.difficulty === d) + const d = parseInt(params.difficulty, 10) + if (!Number.isNaN(d)) { + result = result.filter((e) => e.difficulty === d) + } } return result @@ -155,13 +167,20 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => { if (scope.type === "owned" && exam.creatorId !== scope.userId) { return null } - if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) { - return null + if (scope.type === "grade_managed") { + // P0 fix: empty gradeIds must NOT bypass filtering (would leak exam details) + if (scope.gradeIds.length === 0) return null + if (!scope.gradeIds.includes(exam.gradeId ?? "")) { + return null + } } - if (scope.type === "class_taught" && scope.classIds.length > 0) { + if (scope.type === "class_taught") { + // P0 fix: empty classIds must NOT bypass filtering (would leak exam details) + if (scope.classIds.length === 0) return null const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds) const gradeIds = Array.from(new Set(classGradeMap.values())) - if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) { + if (gradeIds.length === 0) return null + if (!gradeIds.includes(exam.gradeId ?? "")) { return null } } @@ -182,7 +201,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => { createdAt: exam.createdAt.toISOString(), updatedAt: exam.updatedAt?.toISOString(), tags: getStringArray(meta, "tags") || [], - structure: exam.structure as unknown, + structure: exam.structure, questions: exam.questions.map((eqRel) => ({ id: eqRel.questionId, score: eqRel.score ?? 0, @@ -379,14 +398,26 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise 0) { - conditions.push(inArray(exams.gradeId, scope.gradeIds)) + if (scope.type === "grade_managed") { + // P0 fix: empty gradeIds must NOT bypass filtering + if (scope.gradeIds.length === 0) { + conditions.push(eq(exams.id, "__none__")) + } else { + conditions.push(inArray(exams.gradeId, scope.gradeIds)) + } } - if (scope.type === "class_taught" && scope.classIds.length > 0) { - const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds) - const gradeIds = Array.from(new Set(classGradeMap.values())) - if (gradeIds.length > 0) { - conditions.push(inArray(exams.gradeId, gradeIds)) + if (scope.type === "class_taught") { + // P0 fix: empty classIds must NOT bypass filtering + if (scope.classIds.length === 0) { + conditions.push(eq(exams.id, "__none__")) + } else { + const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds) + const gradeIds = Array.from(new Set(classGradeMap.values())) + if (gradeIds.length > 0) { + conditions.push(inArray(exams.gradeId, gradeIds)) + } else { + conditions.push(eq(exams.id, "__none__")) + } } } } diff --git a/src/modules/exams/hooks/use-exam-preview.ts b/src/modules/exams/hooks/use-exam-preview.ts index b91c83b..d61ede8 100644 --- a/src/modules/exams/hooks/use-exam-preview.ts +++ b/src/modules/exams/hooks/use-exam-preview.ts @@ -25,6 +25,17 @@ import { buildPreviewRequestData, } from "../components/exam-preview-utils" +// Runtime validator for parsed preview background tasks. +// Avoids trusting JSON.parse output blindly. +const isPreviewBackgroundTask = (v: unknown): v is PreviewBackgroundTask => { + if (!v || typeof v !== "object") return false + const obj = v as Record + return typeof obj.id === "string" + && (obj.status === "queued" || obj.status === "running" || obj.status === "success" || obj.status === "failed") + && typeof obj.createdAt === "number" + && typeof obj.title === "string" +} + export function useExamPreview(form: UseFormReturn) { const [previewOpen, setPreviewOpen] = useState(false) const [previewLoading, setPreviewLoading] = useState(false) @@ -48,7 +59,7 @@ export function useExamPreview(form: UseFormReturn) { try { window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20))) } catch (error) { - console.error(error) + console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) } } @@ -56,10 +67,16 @@ export function useExamPreview(form: UseFormReturn) { try { const raw = window.localStorage.getItem(previewTaskStorageKey) if (!raw) return - const parsed = JSON.parse(raw) as PreviewBackgroundTask[] + let parsed: unknown = null + try { + parsed = JSON.parse(raw) + } catch (error) { + console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) + return + } if (!Array.isArray(parsed)) return const restoredTasks = parsed - .filter((task) => task && typeof task.id === "string") + .filter(isPreviewBackgroundTask) .map((task) => { if (task.status === "queued" || task.status === "running") { return { @@ -75,7 +92,7 @@ export function useExamPreview(form: UseFormReturn) { form.setValue("mode", "ai") } } catch (error) { - console.error(error) + console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) setPreviewTasks([]) } }, [form]) @@ -150,7 +167,8 @@ export function useExamPreview(form: UseFormReturn) { } else { toast.error(result.message || "Failed to generate preview") } - } catch { + } catch (error) { + console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) toast.error("Failed to generate preview") } finally { setPreviewLoading(false) @@ -201,7 +219,8 @@ export function useExamPreview(form: UseFormReturn) { ? { ...task, status: "failed", message: result.message || "Failed to generate preview" } : task)) toast.error(`后台生成失败:${taskTitle}`) - } catch { + } catch (error) { + console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) setPreviewTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status: "failed", message: "Failed to generate preview" } : task)) @@ -276,7 +295,8 @@ export function useExamPreview(form: UseFormReturn) { updateSelectedQuestionFromAi(selectedQuestionId, result.data) setRewriteInstruction("") toast.success("题目已按指令重写") - } catch { + } catch (error) { + console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) toast.error("AI 重写失败") } finally { setRewritingQuestion(false) diff --git a/src/modules/files/data-access.ts b/src/modules/files/data-access.ts index a20530d..f4aa646 100644 --- a/src/modules/files/data-access.ts +++ b/src/modules/files/data-access.ts @@ -265,6 +265,26 @@ export const getFileStats = cache( }, ) +/** + * 按 URL 查询文件附件(用于头像等场景的旧文件清理) + */ +export const getFileByUrl = cache( + async (url: string): Promise => { + try { + const [row] = await db + .select() + .from(fileAttachments) + .where(eq(fileAttachments.url, url)) + .limit(1) + + return row ? mapRow(row) : null + } catch (error) { + console.error("getFileByUrl failed:", error) + return null + } + }, +) + /** * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) */ diff --git a/src/modules/homework/components/homework-assignment-form.tsx b/src/modules/homework/components/homework-assignment-form.tsx index 02cf158..5631e4a 100644 --- a/src/modules/homework/components/homework-assignment-form.tsx +++ b/src/modules/homework/components/homework-assignment-form.tsx @@ -72,13 +72,18 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[] formData.set("publish", "true") setIsSubmitting(true) - const result = await createHomeworkAssignmentAction(null, formData) - setIsSubmitting(false) - if (result.success) { - toast.success(result.message) - router.push("/teacher/homework/assignments") - } else { - toast.error(result.message || t("homework.form.createFailed")) + try { + const result = await createHomeworkAssignmentAction(null, formData) + if (result.success) { + toast.success(result.message) + router.push("/teacher/homework/assignments") + } else { + toast.error(result.message || t("homework.form.createFailed")) + } + } catch { + toast.error(t("homework.form.createFailed")) + } finally { + setIsSubmitting(false) } } diff --git a/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx b/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx index 76cfa5f..ccbecfc 100644 --- a/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx +++ b/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx @@ -126,8 +126,8 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
) : (
- {wrongAnswers.map((wa, i) => ( -
+ {wrongAnswers.map((wa) => ( +
Student Answer {wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""} diff --git a/src/modules/homework/components/student-homework-review-view.tsx b/src/modules/homework/components/student-homework-review-view.tsx index 6f0fd58..c671aaa 100644 --- a/src/modules/homework/components/student-homework-review-view.tsx +++ b/src/modules/homework/components/student-homework-review-view.tsx @@ -186,11 +186,10 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
{initialData.questions.map((q, i) => { - const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined && - answersByQuestionId[q.questionId]?.answer !== "" && - (Array.isArray(answersByQuestionId[q.questionId]?.answer) - ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 - : true) + const answer = answersByQuestionId[q.questionId]?.answer + const hasAnswer = answer !== undefined && + answer !== "" && + (Array.isArray(answer) ? answer.length > 0 : true) const score = q.score ?? 0 const max = q.maxScore diff --git a/src/modules/homework/hooks/use-debounced-auto-save.ts b/src/modules/homework/hooks/use-debounced-auto-save.ts index 1b53bb2..3ac094a 100644 --- a/src/modules/homework/hooks/use-debounced-auto-save.ts +++ b/src/modules/homework/hooks/use-debounced-auto-save.ts @@ -70,27 +70,40 @@ export function useDebouncedAutoSave({ savingRef.current = true setStatus("saving") - let allOk = true - for (const [questionId, answer] of pending) { - const fd = new FormData() - fd.set("submissionId", submissionId) - fd.set("questionId", questionId) - fd.set("answerJson", JSON.stringify({ answer })) - const res = await saveHomeworkAnswerAction(null, fd) - if (!res.success) { - allOk = false - } - } + // 并行保存所有待保存答案,单个失败不影响其他答案 + const results = await Promise.allSettled( + pending.map(([questionId, answer]) => { + const fd = new FormData() + fd.set("submissionId", submissionId) + fd.set("questionId", questionId) + fd.set("answerJson", JSON.stringify({ answer })) + return saveHomeworkAnswerAction(null, fd) + }) + ) savingRef.current = false - if (allOk) { + // 收集失败的 questionId 以便重试 + const failedQuestionIds: string[] = [] + results.forEach((res, idx) => { + if (res.status !== "fulfilled" || !res.value.success) { + failedQuestionIds.push(pending[idx][0]) + } + }) + + if (failedQuestionIds.length === 0) { pendingRef.current.clear() setStatus("saved") setLastSavedAt(Date.now()) } else { setStatus("error") - // Keep pending items for retry on next change or manual flush + // 仅保留失败的项用于重试,移除已成功的项 + const newPending = new Map() + for (const qid of failedQuestionIds) { + const ans = pendingRef.current.get(qid) + if (ans !== undefined) newPending.set(qid, ans) + } + pendingRef.current = newPending } }, [submissionId]) @@ -135,7 +148,12 @@ export function useDebouncedAutoSave({ if (timerRef.current) { clearTimeout(timerRef.current) } - // Fire-and-forget final save + // 注意:此处无法使用 navigator.sendBeacon,因为保存逻辑调用的是 + // Next.js Server Action(基于 fetch 的 RPC),而非简单的 POST 请求。 + // sendBeacon 仅支持发送原始 body,无法携带 Server Action 所需的 + // 特定 headers 和编码格式。因此采用 fire-and-forget 方式触发最后的 + // 保存,并依赖 localStorage 离线缓存作为兜底(下次进入页面会恢复)。 + // 真正的可靠 flush 由 handleSubmit 在提交前调用 autoSave.flush() 保证。 void savePending() } }, [savePending]) diff --git a/src/modules/homework/lib/question-content-utils.ts b/src/modules/homework/lib/question-content-utils.ts index 29a5f10..4ad8925 100644 --- a/src/modules/homework/lib/question-content-utils.ts +++ b/src/modules/homework/lib/question-content-utils.ts @@ -161,6 +161,49 @@ export const computeIsCorrect = (input: { return null } +/** + * 计算多选题部分分比例(V3-6: 漏选得部分分) + * + * 评分策略: + * - 全部正确选项都选中且无错误选项 → 1.0(满分) + * - 部分正确选项被选中且无错误选项 → 正确选项数 / 总正确选项数 + * - 包含错误选项 → 0(鼓励不猜题) + * - 无标准答案 → null(不自动判分) + * + * @example + * correctIds=[A,B,C], studentIds=[A,B] → 2/3 ≈ 0.667 + * correctIds=[A,B,C], studentIds=[A,B,D] → 0(含错误选项 D) + * correctIds=[A,B,C], studentIds=[A,B,C] → 1.0 + */ +export const computeMultipleChoicePartialRatio = (input: { + questionContent: unknown + studentAnswer: unknown +}): number | null => { + const correctIds = getChoiceCorrectIds(input.questionContent) + if (correctIds.length === 0) return null + + const studentVal = extractAnswerValue(input.studentAnswer) + const studentArr = Array.isArray(studentVal) + ? studentVal.filter((x): x is string => typeof x === "string") + : [] + + const correctSet = new Set(correctIds) + const studentSet = new Set(studentArr) + + // 检查是否有错误选项(学生选了不在正确答案中的选项) + for (const id of studentSet) { + if (!correctSet.has(id)) return 0 + } + + // 无错误选项,按正确选项比例给分 + let correctSelected = 0 + for (const id of studentSet) { + if (correctSet.has(id)) correctSelected += 1 + } + + return correctSelected / correctIds.length +} + /** * 根据分数与满分推断对错状态 */ @@ -193,7 +236,12 @@ export interface AutoGradableAnswer { * 对未判分的题目应用自动判分 * - 已有分数(score !== null)的不覆盖 * - 无标准答案的不判分 - * - 否则按 computeIsCorrect 给满分或 0 分 + * - 多选题支持部分分(漏选得部分分,错选得 0 分) + * - 其他题型按 computeIsCorrect 给满分或 0 分 + * + * V3-6: 多选题部分分策略 + * 使用 computeMultipleChoicePartialRatio 计算比例分数 + * 例如:maxScore=6, 正确选项[A,B,C], 学生选[A,B] → 6 * (2/3) = 4 分 */ export const applyAutoGrades = (incoming: T[]): T[] => { return incoming.map((a) => { @@ -201,6 +249,19 @@ export const applyAutoGrades = (incoming: T[]): T[ if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) { return a } + + // V3-6: 多选题使用部分分策略 + if (a.questionType === "multiple_choice") { + const ratio = computeMultipleChoicePartialRatio({ + questionContent: a.questionContent, + studentAnswer: a.studentAnswer, + }) + if (ratio === null) return a + // 按比例计算分数,四舍五入到整数(DB schema score 为整数) + const scaledScore = Math.round(a.maxScore * ratio) + return { ...a, score: scaledScore } + } + const isCorrect = computeIsCorrect({ questionType: a.questionType, questionContent: a.questionContent, @@ -211,6 +272,63 @@ export const applyAutoGrades = (incoming: T[]): T[ }) } +/** + * V3-2: 服务端即时自动批改 + * + * 与 applyAutoGrades 类似,但用于学生提交时服务端回写。 + * 返回批改结果数组(含 score 和 feedback),以及是否全部可自动判分。 + * + * @returns { answers: 批改后的答案数组, isFullyAutoGraded: 是否全部题目可自动判分 } + */ +export const autoGradeSubmission = ( + incoming: T[] +): { answers: Array; isFullyAutoGraded: boolean } => { + const graded: Array = [] + let hasUngradable = false + + for (const a of incoming) { + if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) { + // 主观题:保留原 score(可能为 null),标记为不可全自动判分 + hasUngradable = true + graded.push({ ...a, score: a.score ?? 0 }) + continue + } + + // 多选题使用部分分策略 + if (a.questionType === "multiple_choice") { + const ratio = computeMultipleChoicePartialRatio({ + questionContent: a.questionContent, + studentAnswer: a.studentAnswer, + }) + if (ratio === null) { + hasUngradable = true + graded.push({ ...a, score: a.score ?? 0 }) + continue + } + const scaledScore = Math.round(a.maxScore * ratio) + graded.push({ ...a, score: scaledScore }) + continue + } + + const isCorrect = computeIsCorrect({ + questionType: a.questionType, + questionContent: a.questionContent, + studentAnswer: a.studentAnswer, + }) + if (isCorrect === null) { + hasUngradable = true + graded.push({ ...a, score: a.score ?? 0 }) + continue + } + graded.push({ ...a, score: isCorrect ? a.maxScore : 0 }) + } + + return { + answers: graded, + isFullyAutoGraded: !hasUngradable, + } +} + /** * 格式化学生答案为可读字符串 */ diff --git a/src/modules/homework/schema.ts b/src/modules/homework/schema.ts index 37f274e..c2d5d08 100644 --- a/src/modules/homework/schema.ts +++ b/src/modules/homework/schema.ts @@ -1,18 +1,51 @@ import { z } from "zod" -export const CreateHomeworkAssignmentSchema = z.object({ - sourceExamId: z.string().optional(), - classId: z.string().min(1), - title: z.string().min(1, "Title is required for quick assignments"), - description: z.string().optional(), - availableAt: z.string().optional(), - dueAt: z.string().optional(), - allowLate: z.coerce.boolean().optional(), - lateDueAt: z.string().optional(), - maxAttempts: z.coerce.number().int().min(1).max(20).optional(), - targetStudentIds: z.array(z.string().min(1)).optional(), - publish: z.coerce.boolean().optional(), -}) +const dateStringSchema = z + .string() + .refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format") + +export const CreateHomeworkAssignmentSchema = z + .object({ + sourceExamId: z.string().optional(), + classId: z.string().min(1), + title: z.string().min(1, "Title is required for quick assignments"), + description: z.string().optional(), + availableAt: dateStringSchema.optional(), + dueAt: dateStringSchema.optional(), + allowLate: z.coerce.boolean().optional(), + lateDueAt: dateStringSchema.optional(), + maxAttempts: z.coerce.number().int().min(1).max(20).optional(), + targetStudentIds: z.array(z.string().min(1)).optional(), + publish: z.coerce.boolean().optional(), + }) + .superRefine((data, ctx) => { + // 时序校验:availableAt < dueAt < lateDueAt + const available = data.availableAt ? new Date(data.availableAt).getTime() : null + const due = data.dueAt ? new Date(data.dueAt).getTime() : null + const lateDue = data.lateDueAt ? new Date(data.lateDueAt).getTime() : null + + if (available !== null && due !== null && available > due) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["dueAt"], + message: "截止时间必须晚于可用时间", + }) + } + if (due !== null && lateDue !== null && due > lateDue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["lateDueAt"], + message: "迟交截止时间必须晚于正常截止时间", + }) + } + if (data.allowLate && !data.lateDueAt) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["lateDueAt"], + message: "允许迟交时必须设置迟交截止时间", + }) + } + }) export type CreateHomeworkAssignmentInput = z.infer diff --git a/src/modules/homework/stats-service.ts b/src/modules/homework/stats-service.ts index f3ded26..a60ee9e 100644 --- a/src/modules/homework/stats-service.ts +++ b/src/modules/homework/stats-service.ts @@ -115,36 +115,34 @@ export const getHomeworkAssignmentAnalytics = cache( if (!assignment) return null - const [targetsRow] = await db - .select({ c: count() }) - .from(homeworkAssignmentTargets) - .where(eq(homeworkAssignmentTargets.assignmentId, assignmentId)) - - const [submissionsRow] = await db - .select({ c: count() }) - .from(homeworkSubmissions) - .where(eq(homeworkSubmissions.assignmentId, assignmentId)) - - const [submittedRow] = await db - .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) - .from(homeworkSubmissions) - .where( - and( - eq(homeworkSubmissions.assignmentId, assignmentId), - inArray(homeworkSubmissions.status, ["submitted", "graded"]) - ) - ) - - const [gradedRow] = await db - .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) - .from(homeworkSubmissions) - .where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded"))) - - const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({ - where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId), - with: { question: true }, - orderBy: (q, { asc }) => [asc(q.order)], - }) + const [targetsRows, submissionsRows, submittedRows, gradedRows, assignmentQuestions] = await Promise.all([ + db + .select({ c: count() }) + .from(homeworkAssignmentTargets) + .where(eq(homeworkAssignmentTargets.assignmentId, assignmentId)), + db + .select({ c: count() }) + .from(homeworkSubmissions) + .where(eq(homeworkSubmissions.assignmentId, assignmentId)), + db + .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) + .from(homeworkSubmissions) + .where( + and( + eq(homeworkSubmissions.assignmentId, assignmentId), + inArray(homeworkSubmissions.status, ["submitted", "graded"]) + ) + ), + db + .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) + .from(homeworkSubmissions) + .where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded"))), + db.query.homeworkAssignmentQuestions.findMany({ + where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId), + with: { question: true }, + orderBy: (q, { asc }) => [asc(q.order)], + }), + ]) const statsByQuestionId = new Map() @@ -235,10 +233,10 @@ export const getHomeworkAssignmentAnalytics = cache( allowLate: assignment.allowLate, lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null, maxAttempts: assignment.maxAttempts, - targetCount: targetsRow?.c ?? 0, - submissionCount: submissionsRow?.c ?? 0, - submittedCount: submittedRow?.c ?? 0, - gradedCount: gradedRow?.c ?? 0, + targetCount: targetsRows[0]?.c ?? 0, + submissionCount: submissionsRows[0]?.c ?? 0, + submittedCount: submittedRows[0]?.c ?? 0, + gradedCount: gradedRows[0]?.c ?? 0, createdAt: assignment.createdAt.toISOString(), updatedAt: assignment.updatedAt.toISOString(), }, diff --git a/src/modules/layout/components/app-sidebar.tsx b/src/modules/layout/components/app-sidebar.tsx index edbd9fe..c018341 100644 --- a/src/modules/layout/components/app-sidebar.tsx +++ b/src/modules/layout/components/app-sidebar.tsx @@ -11,13 +11,6 @@ import { CollapsibleTrigger, } from "@/shared/components/ui/collapsible" import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select" import { Tooltip, TooltipContent, @@ -35,11 +28,13 @@ interface AppSidebarProps { } export function AppSidebar({ mode }: AppSidebarProps) { - const { expanded, toggleSidebar, isMobile, currentRole, setCurrentRole } = useSidebar() + const { expanded, toggleSidebar, isMobile } = useSidebar() const pathname = usePathname() - const { permissions, roles, hasRole } = usePermission() + const { permissions, hasRole } = usePermission() // 自动检测当前角色(优先级 admin > student > parent > teacher) + // 注意:grade_head / teaching_head 统一归入 teacher,因为 teacher 导航已通过 + // 权限点(GRADE_MANAGE 等)动态显示班主任专属功能,无需切换角色。 function detectAutoRole(): Role { if (hasRole("admin")) return "admin" if (hasRole("student")) return "student" @@ -47,14 +42,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { return "teacher" } - // 用户在 NAV_CONFIG 中实际可用的角色(过滤掉未配置的角色) - const availableRoles = roles.filter((r) => NAV_CONFIG[r] !== undefined) - - // 如果 context 中有 currentRole 且用户拥有该角色,使用 currentRole;否则自动检测 - const effectiveRole: Role = - currentRole !== null && availableRoles.includes(currentRole) - ? currentRole - : detectAutoRole() + const effectiveRole: Role = detectAutoRole() const allNavItems = NAV_CONFIG[effectiveRole] ?? NAV_CONFIG.teacher ?? [] @@ -71,7 +59,7 @@ export function AppSidebar({ mode }: AppSidebarProps) { })) // Ensure consistent state for hydration - if (!expanded && mode === 'mobile') return null + if (!expanded && mode === 'mobile') return null return (
@@ -179,26 +167,12 @@ export function AppSidebar({ mode }: AppSidebarProps) { {/* Sidebar Footer */}
- {availableRoles.length > 1 && (expanded || isMobile) && ( -
- -
- )} {!isMobile && ( )}
diff --git a/src/modules/layout/components/sidebar-provider.tsx b/src/modules/layout/components/sidebar-provider.tsx index f8b5ec2..eefe41c 100644 --- a/src/modules/layout/components/sidebar-provider.tsx +++ b/src/modules/layout/components/sidebar-provider.tsx @@ -9,15 +9,12 @@ import { SheetTitle, } from "@/shared/components/ui/sheet" import { cn } from "@/shared/lib/utils" -import type { Role } from "@/shared/types/permissions" type SidebarContextType = { expanded: boolean setExpanded: (expanded: boolean) => void isMobile: boolean toggleSidebar: () => void - currentRole: Role | null - setCurrentRole: (role: Role | null) => void } const SidebarContext = React.createContext( @@ -41,8 +38,6 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) { const [expanded, setExpanded] = React.useState(true) const [isMobile, setIsMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false) - // null 表示自动检测(按现有优先级 admin > student > parent > teacher) - const [currentRole, setCurrentRole] = React.useState(null) React.useEffect(() => { const checkMobile = () => { @@ -67,7 +62,7 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) { return (
{/* Mobile Trigger & Sheet */} diff --git a/src/modules/layout/components/site-header.tsx b/src/modules/layout/components/site-header.tsx index 0baa7cc..4dce60f 100644 --- a/src/modules/layout/components/site-header.tsx +++ b/src/modules/layout/components/site-header.tsx @@ -27,7 +27,7 @@ import { } from "@/shared/components/ui/dropdown-menu" import { GlobalSearch } from "@/shared/components/global-search" -import { NotificationDropdown } from "@/modules/messaging/components/notification-dropdown" +import { NotificationDropdown } from "@/modules/notifications/components/notification-dropdown" import { useSidebar } from "./sidebar-provider" import { NAV_CONFIG } from "../config/navigation" diff --git a/src/modules/layout/config/navigation.ts b/src/modules/layout/config/navigation.ts index e895174..3e22507 100644 --- a/src/modules/layout/config/navigation.ts +++ b/src/modules/layout/config/navigation.ts @@ -21,6 +21,7 @@ import { BookMarked, BookCopy, Files, + BookX, } from "lucide-react" import type { LucideIcon } from "lucide-react" import { Permissions } from "@/shared/types/permissions" @@ -127,6 +128,12 @@ export const NAV_CONFIG: Partial> = { href: "/admin/files", permission: Permissions.FILE_READ, }, + { + title: "错题分析", + icon: BookX, + href: "/admin/error-book", + permission: Permissions.ERROR_BOOK_ANALYTICS_READ, + }, { title: "Audit Logs", icon: ScrollText, @@ -242,6 +249,12 @@ export const NAV_CONFIG: Partial> = { href: "/teacher/diagnostic", permission: Permissions.DIAGNOSTIC_READ, }, + { + title: "错题分析", + icon: BookX, + href: "/teacher/error-book", + permission: Permissions.ERROR_BOOK_ANALYTICS_READ, + }, { title: "选修课", icon: BookMarked, @@ -297,6 +310,12 @@ export const NAV_CONFIG: Partial> = { { title: "成绩分析", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, ] }, + { + title: "错题分析", + icon: BookX, + href: "/teacher/error-book", + permission: Permissions.ERROR_BOOK_ANALYTICS_READ, + }, ...COMMON_NAV_ITEMS, ], teaching_head: [ @@ -336,6 +355,12 @@ export const NAV_CONFIG: Partial> = { { title: "成绩分析", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ }, ] }, + { + title: "错题分析", + icon: BookX, + href: "/teacher/error-book", + permission: Permissions.ERROR_BOOK_ANALYTICS_READ, + }, ...COMMON_NAV_ITEMS, ], student: [ @@ -379,6 +404,12 @@ export const NAV_CONFIG: Partial> = { href: "/student/diagnostic", permission: Permissions.DIAGNOSTIC_READ, }, + { + title: "错题本", + icon: BookX, + href: "/student/error-book", + permission: Permissions.ERROR_BOOK_READ, + }, { title: "Electives", icon: BookMarked, @@ -405,6 +436,12 @@ export const NAV_CONFIG: Partial> = { href: "/parent/attendance", permission: Permissions.ATTENDANCE_READ, }, + { + title: "错题本", + icon: BookX, + href: "/parent/error-book", + permission: Permissions.ERROR_BOOK_READ, + }, { title: "Leave Request", icon: CalendarRange, diff --git a/src/modules/proctoring/actions.ts b/src/modules/proctoring/actions.ts index 47535cf..1f165e5 100644 --- a/src/modules/proctoring/actions.ts +++ b/src/modules/proctoring/actions.ts @@ -2,10 +2,8 @@ import { revalidatePath } from "next/cache" import type { ActionState } from "@/shared/types/action-state" -import { - requirePermission, - PermissionDeniedError, -} from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { handleActionError } from "@/shared/lib/action-utils" import { Permissions } from "@/shared/types/permissions" import { z } from "zod" @@ -91,11 +89,7 @@ export async function recordProctoringEventAction( return successState({ id: event.id }, "Event recorded") } catch (error) { - if (error instanceof PermissionDeniedError) { - return failState<{ id: string }>(error.message) - } - console.error("recordProctoringEventAction error:", error) - return failState<{ id: string }>("Failed to record proctoring event") + return handleActionError(error) } } @@ -130,10 +124,6 @@ export async function getProctoringDashboardAction( recentEvents, }) } catch (error) { - if (error instanceof PermissionDeniedError) { - return failState(error.message) - } - console.error("getProctoringDashboardAction error:", error) - return failState("Failed to load proctoring dashboard") + return handleActionError(error) } } diff --git a/src/modules/proctoring/components/anti-cheat-monitor.tsx b/src/modules/proctoring/components/anti-cheat-monitor.tsx index 2101f19..09a3768 100644 --- a/src/modules/proctoring/components/anti-cheat-monitor.tsx +++ b/src/modules/proctoring/components/anti-cheat-monitor.tsx @@ -10,6 +10,7 @@ import { PROCTORING_EVENT_LABELS } from "../types" const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟 const REPORT_THROTTLE_MS = 1500 // 同类事件最小上报间隔 +const ACTIVITY_THROTTLE_MS = 1000 // 用户活动事件节流间隔(mousemove 等高频事件) type AntiCheatMonitorProps = { examId: string @@ -144,7 +145,12 @@ export function AntiCheatMonitor({ } } + let lastActivityAt = 0 const handleUserActivity = () => { + // 节流:mousemove 等高频事件每秒最多触发一次 resetIdleTimer + const now = Date.now() + if (now - lastActivityAt < ACTIVITY_THROTTLE_MS) return + lastActivityAt = now resetIdleTimer() } diff --git a/src/modules/proctoring/data-access.ts b/src/modules/proctoring/data-access.ts index c77633a..a706633 100644 --- a/src/modules/proctoring/data-access.ts +++ b/src/modules/proctoring/data-access.ts @@ -13,6 +13,7 @@ import { getExamTitleById, } from "@/modules/exams/data-access" import { getUserNamesByIds } from "@/modules/users/data-access" +import { safeParseDate } from "@/shared/lib/action-utils" import type { ProctoringEvent, @@ -123,10 +124,20 @@ export const getProctoringEvents = cache( conditions.push(eq(examProctoringEvents.eventType, filters.eventType)) } if (filters?.startedAt) { - conditions.push(gte(examProctoringEvents.occurredAt, new Date(filters.startedAt))) + conditions.push( + gte( + examProctoringEvents.occurredAt, + safeParseDate(filters.startedAt, "开始时间"), + ), + ) } if (filters?.endedAt) { - conditions.push(lte(examProctoringEvents.occurredAt, new Date(filters.endedAt))) + conditions.push( + lte( + examProctoringEvents.occurredAt, + safeParseDate(filters.endedAt, "结束时间"), + ), + ) } const rows = await db diff --git a/src/modules/questions/actions.ts b/src/modules/questions/actions.ts index 6614657..92ce5bb 100644 --- a/src/modules/questions/actions.ts +++ b/src/modules/questions/actions.ts @@ -1,6 +1,6 @@ "use server" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { CreateQuestionSchema } from "./schema" import type { CreateQuestionInput } from "./schema" @@ -16,6 +16,7 @@ import { type GetQuestionsParams, } from "./data-access" import type { KnowledgePointOption } from "./types" +import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils" /** Result type of getQuestions (data + meta) */ type QuestionsListResult = Awaited> @@ -35,7 +36,7 @@ export async function createQuestionAction( if (formData instanceof FormData) { const jsonString = formData.get("json") if (typeof jsonString === "string") { - rawInput = JSON.parse(jsonString) as unknown + rawInput = safeJsonParse(jsonString, "题目内容格式无效") } else { return { success: false, message: "Invalid submission format. Expected JSON." } } @@ -53,29 +54,17 @@ export async function createQuestionAction( const input = validatedFields.data - await createQuestionWithRelations(input, ctx.userId) + const questionId = await createQuestionWithRelations(input, ctx.userId) revalidatePath("/teacher/questions") return { success: true, message: "Question created successfully", + data: questionId, } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) { - return { - success: false, - message: e.message || "Database error occurred", - } - } - - return { - success: false, - message: "An unexpected error occurred", - } + return handleActionError(e) } } @@ -83,7 +72,7 @@ const UpdateQuestionSchema = z.object({ id: z.string().min(1), type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]), difficulty: z.number().min(1).max(5), - content: z.unknown(), + content: z.record(z.string(), z.unknown()), knowledgePointIds: z.array(z.string()).optional(), }) @@ -100,7 +89,7 @@ export async function updateQuestionAction( return { success: false, message: "Invalid submission format. Expected JSON." } } - const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString)) + const parsed = UpdateQuestionSchema.safeParse(safeJsonParse(jsonString, "题目内容格式无效")) if (!parsed.success) { return { success: false, @@ -115,15 +104,9 @@ export async function updateQuestionAction( revalidatePath("/teacher/questions") - return { success: true, message: "Question updated successfully" } + return { success: true, message: "Question updated successfully", data: id } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) { - return { success: false, message: e.message } - } - return { success: false, message: "An unexpected error occurred" } + return handleActionError(e) } } @@ -144,15 +127,9 @@ export async function deleteQuestionAction( revalidatePath("/teacher/questions") - return { success: true, message: "Question deleted successfully" } + return { success: true, message: "Question deleted successfully", data: questionId } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) { - return { success: false, message: e.message } - } - return { success: false, message: "Failed to delete question" } + return handleActionError(e) } } @@ -164,11 +141,7 @@ export async function getQuestionsAction( const data = await getQuestions(params) return { success: true, data } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - const message = e instanceof Error ? e.message : "Failed to fetch questions" - return { success: false, message } + return handleActionError(e) } } @@ -180,10 +153,6 @@ export async function getKnowledgePointOptionsAction(): Promise< const data = await getKnowledgePointOptions() return { success: true, data } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options" - return { success: false, message } + return handleActionError(e) } } diff --git a/src/modules/questions/components/create-question-dialog.tsx b/src/modules/questions/components/create-question-dialog.tsx index 674db95..0d80a51 100644 --- a/src/modules/questions/components/create-question-dialog.tsx +++ b/src/modules/questions/components/create-question-dialog.tsx @@ -19,27 +19,17 @@ import { } from "@/shared/components/ui/dialog" import { Form, - FormControl, - FormDescription, - FormField, - FormItem, FormLabel, - FormMessage, } from "@/shared/components/ui/form" import { Input } from "@/shared/components/ui/input" import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select" -import { Textarea } from "@/shared/components/ui/textarea" +import { SelectField } from "@/shared/components/form-fields/select-field" +import { TextareaField } from "@/shared/components/form-fields/textarea-field" import { BaseQuestionSchema } from "../schema" import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions" import { toast } from "sonner" -import { KnowledgePointOption, Question } from "../types" +import { Question } from "../types" +import { useActionQuery } from "@/shared/hooks/use-action-query" const QuestionFormSchema = BaseQuestionSchema.extend({ difficulty: z.number().min(1).max(5), @@ -112,10 +102,14 @@ export function CreateQuestionDialog({ const router = useRouter() const [isPending, setIsPending] = useState(false) const isEdit = !!initialData - const [knowledgePointOptions, setKnowledgePointOptions] = useState([]) const [knowledgePointQuery, setKnowledgePointQuery] = useState("") const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState([]) - const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false) + + const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery( + () => getKnowledgePointOptionsAction(), + { deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" } + ) + const knowledgePointOptions = knowledgePointOptionsData ?? [] const form = useForm({ resolver: zodResolver(QuestionFormSchema), @@ -156,21 +150,6 @@ export function CreateQuestionDialog({ } }, [initialData, form, open, defaultContent, defaultType]) - useEffect(() => { - if (!open) return - setIsLoadingKnowledgePoints(true) - getKnowledgePointOptionsAction() - .then((result) => { - setKnowledgePointOptions(result.success && result.data ? result.data : []) - }) - .catch(() => { - toast.error("Failed to load knowledge points") - }) - .finally(() => { - setIsLoadingKnowledgePoints(false) - }) - }, [open]) - useEffect(() => { if (!open) return if (initialData) { @@ -269,7 +248,8 @@ export function CreateQuestionDialog({ } else { toast.error(res.message || "Operation failed") } - } catch { + } catch (e) { + console.error("Failed to submit question", e) toast.error("Unexpected error") } finally { setIsPending(false) @@ -289,79 +269,43 @@ export function CreateQuestionDialog({
- ( - - Question Type - - - - )} + label="Question Type" + placeholder="Select type" + options={[ + { value: "single_choice", label: "Single Choice" }, + { value: "multiple_choice", label: "Multiple Choice" }, + { value: "judgment", label: "True/False" }, + { value: "text", label: "Short Answer" }, + { value: "composite", label: "Composite" }, + ]} /> - - ( - - Difficulty (1-5) - - - - )} + label="Difficulty (1-5)" + placeholder="Select difficulty" + toSelectValue={(v) => String(v)} + fromSelectValue={(val) => { + const n = parseInt(val, 10) + return Number.isFinite(n) ? n : 1 + }} + options={[1, 2, 3, 4, 5].map((level) => ({ + value: String(level), + label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`, + }))} />
- ( - - Question Content - -