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

- Update attendance components and data-access for record management

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

- Update auth login and register forms

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

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

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

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

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

- Update layout sidebar, header, and navigation config

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

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

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

- Update textbooks constants and text-selection hook

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

View File

@@ -43,8 +43,8 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
const handleDelete = async () => {
if (!deleteId) return
setIsDeleting(true)
try {
const result = await deleteAttendanceAction(deleteId)
setIsDeleting(false)
if (result.success) {
toast.success(result.message || t("sheet.deleted"))
setDeleteId(null)
@@ -52,6 +52,11 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
} else {
toast.error(result.message || t("errors.unexpected"))
}
} catch {
toast.error(t("errors.unexpected"))
} finally {
setIsDeleting(false)
}
}
if (records.length === 0) {

View File

@@ -72,6 +72,7 @@ export function AttendanceRulesForm({
formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
try {
const result = await saveAttendanceRulesAction(null, formData)
if (result.success) {
toast.success(result.message || t("rules.saved"))
@@ -79,6 +80,9 @@ export function AttendanceRulesForm({
} else {
toast.error(result.message || t("errors.unexpected"))
}
} catch {
toast.error(t("errors.unexpected"))
}
}
return (

View File

@@ -210,8 +210,8 @@ export function AttendanceSheet({
setIsSubmitting(true)
formData.set("recordsJson", JSON.stringify(records))
try {
const result = await batchRecordAttendanceAction(null, formData)
setIsSubmitting(false)
if (result.success) {
toast.success(result.message || t("sheet.saved"))
router.push("/teacher/attendance")
@@ -219,6 +219,11 @@ export function AttendanceSheet({
} else {
toast.error(result.message || t("errors.unexpected"))
}
} catch {
toast.error(t("errors.unexpected"))
} finally {
setIsSubmitting(false)
}
}
return (

View File

@@ -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 key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={`flex h-8 w-8 items-center justify-center rounded-md ${card.bgColor}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>

View File

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

View File

@@ -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 (
<div className="space-y-4">
<div className="rounded-md border">
@@ -52,11 +50,7 @@ export function AuditLogTable({
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No audit logs found.
</TableCell>
</TableRow>
<EmptyTableRow colSpan={7} message="No audit logs found." />
) : (
items.map((log) => (
<TableRow key={log.id}>
@@ -85,7 +79,11 @@ export function AuditLogTable({
)}
</TableCell>
<TableCell>
<StatusBadge status={log.status} />
<StatusBadge
status={log.status}
variantMap={AUDIT_STATUS_VARIANT}
classNameMap={AUDIT_STATUS_CLASS_NAME}
/>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{log.ipAddress ?? "-"}
@@ -100,57 +98,13 @@ export function AuditLogTable({
</Table>
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> logs
</>
) : (
"No logs"
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
)
}
function StatusBadge({ status }: { status: "success" | "failure" }) {
const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive"
return (
<Badge
variant={variant}
className={cn(
"capitalize",
status === "success" && "bg-green-600 hover:bg-green-700 border-transparent"
)}
>
{status}
</Badge>
)
}

View File

@@ -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 (
<div className="space-y-4">
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
@@ -83,11 +85,7 @@ function DataChangeLogTableInner({
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No data change logs found.
</TableCell>
</TableRow>
<EmptyTableRow colSpan={7} message="No data change logs found." />
) : (
items.map((log) => (
<Fragment key={log.id}>
@@ -99,7 +97,11 @@ function DataChangeLogTableInner({
</TableCell>
<TableCell className="font-mono text-xs">{log.recordId}</TableCell>
<TableCell>
<ActionBadge action={log.action} />
<StatusBadge
status={log.action}
variantMap={DATA_CHANGE_ACTION_VARIANT}
classNameMap={DATA_CHANGE_ACTION_CLASS_NAME}
/>
</TableCell>
<TableCell>
<div className="flex flex-col">
@@ -154,42 +156,13 @@ function DataChangeLogTableInner({
</Table>
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> logs
</>
) : (
"No logs"
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)
}
@@ -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 (
<Badge
variant={variant}
className={cn(
"capitalize",
action === "create" && "bg-green-600 hover:bg-green-700 border-transparent",
action === "delete" && "bg-red-600 hover:bg-red-700 border-transparent"
)}
>
{action}
</Badge>
)
}
export function DataChangeLogTable(props: DataChangeLogTableProps) {
return (
<Suspense fallback={null}>

View File

@@ -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 (
<div className="space-y-4">
<div className="rounded-md border">
@@ -51,11 +49,7 @@ export function LoginLogTable({
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
No login logs found.
</TableCell>
</TableRow>
<EmptyTableRow colSpan={6} message="No login logs found." />
) : (
items.map((log) => (
<TableRow key={log.id}>
@@ -73,7 +67,11 @@ export function LoginLogTable({
</Badge>
</TableCell>
<TableCell>
<StatusBadge status={log.status} />
<StatusBadge
status={log.status}
variantMap={AUDIT_STATUS_VARIANT}
classNameMap={AUDIT_STATUS_CLASS_NAME}
/>
{log.errorMessage && (
<div className="mt-1 text-xs text-destructive">{log.errorMessage}</div>
)}
@@ -94,57 +92,13 @@ export function LoginLogTable({
</Table>
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> logs
</>
) : (
"No logs"
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
)
}
function StatusBadge({ status }: { status: "success" | "failure" }) {
const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive"
return (
<Badge
variant={variant}
className={cn(
"capitalize",
status === "success" && "bg-green-600 hover:bg-green-700 border-transparent"
)}
>
{status}
</Badge>
)
}

View File

@@ -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<AuditLogStatus> = {
success: "default",
failure: "destructive",
}
/** 审计日志/登录日志 success/failure 状态 → 附加 className 映射 */
export const AUDIT_STATUS_CLASS_NAME: StatusClassNameMap<AuditLogStatus> = {
success: "bg-green-600 hover:bg-green-700 border-transparent",
}
/** 数据变更 create/update/delete 动作 → Badge variant 映射 */
export const DATA_CHANGE_ACTION_VARIANT: StatusVariantMap<DataChangeAction> = {
create: "default",
update: "secondary",
delete: "destructive",
}
/** 数据变更 create/update/delete 动作 → 附加 className 映射 */
export const DATA_CHANGE_ACTION_CLASS_NAME: StatusClassNameMap<DataChangeAction> = {
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

View File

@@ -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<HTMLDivElement>
export function LoginForm({ className, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [requiresTwoFactor, setRequiresTwoFactor] = React.useState<boolean>(false)
const [totpCode, setTotpCode] = React.useState<string>("")
const [error, setError] = React.useState<string>("")
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,11 +76,15 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
Welcome back
</h1>
<p className="text-sm text-muted-foreground">
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"}
</p>
</div>
<form onSubmit={onSubmit}>
<div className="grid gap-4">
{!requiresTwoFactor ? (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
@@ -85,11 +116,51 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
disabled={isLoading}
/>
</div>
</>
) : (
<div className="grid gap-2">
<Label htmlFor="totpCode" className="flex items-center gap-1.5">
<ShieldCheck className="h-4 w-4" />
2FA Code
</Label>
<Input
id="totpCode"
name="totpCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={8}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
disabled={isLoading}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Enter your 6-digit authenticator code or an 8-character backup code.
</p>
<button
type="button"
onClick={() => {
setRequiresTwoFactor(false)
setTotpCode("")
setError("")
}}
className="text-xs text-muted-foreground hover:underline justify-self-start"
disabled={isLoading}
>
Back to login
</button>
</div>
)}
{error ? (
<p className="text-sm text-red-600">{error}</p>
) : null}
<Button disabled={isLoading}>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Sign In with Email
{requiresTwoFactor ? "Verify & Sign In" : "Sign In with Email"}
</Button>
</div>
</form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<never> => {
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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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<string>("Database error: Failed to create exam")
}
@@ -356,7 +364,7 @@ export async function createExamAction(
if (error instanceof PermissionDeniedError) {
return failState<string>(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<string>("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<string>(error.message)
}
throw error
return handleActionError(error)
}
}
@@ -529,7 +547,7 @@ export async function previewAiExamAction(
if (error instanceof PermissionDeniedError) {
return failState<AiPreviewData>(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<AiRewriteQuestionData>("AI question format invalid")
}
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AiRewriteQuestionData>(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<string>("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<string>(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<string>("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<string>(error.message)
}
throw error
return handleActionError(error)
}
}
@@ -727,18 +761,26 @@ export async function duplicateExamAction(
return failState<string>("Exam not found")
}
newExamId = duplicatedId
} catch {
} catch (error) {
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
return failState<string>("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<string>(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<ActionState<{ id: string; nam
const allSubjects = await getExamSubjects()
return successState(allSubjects)
} catch (error) {
console.error("Failed to fetch subjects:", error)
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
return failState<{ id: string; name: string }[]>("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<ActionState<{ id: string; name:
const allGrades = await getExamGrades()
return successState(allGrades)
} catch (error) {
console.error("Failed to fetch grades:", error)
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
return failState<{ id: string; name: string }[]>("Failed to load grades")
}
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<{ id: string; name: string }[]>(error.message)
}
throw error
return handleActionError(error)
}
}

View File

@@ -73,7 +73,13 @@ export const mapWithConcurrency = async <T, R>(
while (cursor < items.length) {
const index = cursor
cursor += 1
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())

View File

@@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import type { ExamNode } from "./selected-question-list"
type ChoiceOption = {
@@ -21,10 +22,6 @@ 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") {
@@ -39,6 +36,28 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
return {}
}
// Precompute question numbers as a Map to avoid mutating a counter during render
const buildQuestionNumberMap = (nodes: ExamNode[]): Map<string, number> => {
const map = new Map<string, number>()
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)
}
}
}
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') {
return (
@@ -57,14 +76,14 @@ 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 (
<div key={node.id} className="mb-6 break-inside-avoid">
<div className="flex gap-2">
<span className="font-semibold text-foreground min-w-[24px]">{questionCounter}.</span>
<span className="font-semibold text-foreground min-w-[24px]">{questionNumber}.</span>
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text ?? ""}

View File

@@ -68,7 +68,7 @@ export function SelectedQuestionList({
</div>
<div className="pl-4 border-l-2 border-muted space-y-3">
{node.children?.length === 0 ? (
{!node.children || node.children.length === 0 ? (
<div className="text-xs text-muted-foreground italic py-2">Drag questions here or add from bank</div>
) : (
node.children?.map((child, cIdx) => (
@@ -197,7 +197,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
min={0}
className="h-7 w-16 text-right"
value={item.score}
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>

View File

@@ -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<string, unknown>
return typeof obj.text === "string" ? obj.text : ""
}
return raw
} catch {
return raw
}
}
if (typeof raw === "object") {
const obj = raw as Record<string, unknown>
return typeof obj.text === "string" ? obj.text : ""
}
return ""
}
// --- Components ---
function SortableItem({
@@ -141,7 +165,7 @@ function SortableItem({
min={0}
className="h-7 w-16 text-right"
value={item.score}
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
@@ -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 (
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border bg-muted/10 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
@@ -234,6 +260,7 @@ function StructureRenderer({ nodes, ...props }: {
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 (
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
@@ -303,27 +331,30 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
)
// Recursively find item
const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => {
for (const node of nodes) {
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 = findItem(id, node.children)
const found = walk(node.children)
if (found) return found
}
}
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)
@@ -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
@@ -561,11 +592,11 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
// 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
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
<GripVertical className="h-4 w-4" />
<p className="text-sm line-clamp-1">
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
{extractQuestionText(activeItem.question?.content) || "Question"}
</p>
</div>
)

View File

@@ -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<string, number> => {
const map = new Map<string, number>()
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({
)}
>
<div className="flex gap-2">
<span className="font-semibold text-foreground min-w-[28px]">{questionCounter}.</span>
<span className="font-semibold text-foreground min-w-[28px]">{questionNumber}.</span>
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text || "未命名题目"}

View File

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

View File

@@ -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,9 +136,11 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
})
if (params.difficulty && params.difficulty !== "all") {
const d = parseInt(params.difficulty)
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 ?? "")) {
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<E
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
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) {
}
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__"))
}
}
}
}

View File

@@ -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<string, unknown>
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<ExamFormValues>) {
const [previewOpen, setPreviewOpen] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
@@ -48,7 +59,7 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
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<ExamFormValues>) {
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<ExamFormValues>) {
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<ExamFormValues>) {
} 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<ExamFormValues>) {
? { ...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<ExamFormValues>) {
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)

View File

@@ -265,6 +265,26 @@ export const getFileStats = cache(
},
)
/**
* 按 URL 查询文件附件(用于头像等场景的旧文件清理)
*/
export const getFileByUrl = cache(
async (url: string): Promise<FileAttachment | null> => {
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 列表批量查询文件(用于批量删除前获取磁盘路径)
*/

View File

@@ -72,14 +72,19 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
formData.set("publish", "true")
setIsSubmitting(true)
try {
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"))
}
} catch {
toast.error(t("homework.form.createFailed"))
} finally {
setIsSubmitting(false)
}
}
return (

View File

@@ -126,8 +126,8 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
</div>
) : (
<div className="space-y-3">
{wrongAnswers.map((wa, i) => (
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
{wrongAnswers.map((wa) => (
<div key={wa.studentId} className="rounded-md border bg-background p-3 text-sm shadow-sm">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>

View File

@@ -186,11 +186,10 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{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

View File

@@ -70,27 +70,40 @@ export function useDebouncedAutoSave({
savingRef.current = true
setStatus("saving")
let allOk = true
for (const [questionId, answer] of pending) {
// 并行保存所有待保存答案,单个失败不影响其他答案
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 }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
allOk = false
}
}
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<string, unknown>()
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])

View File

@@ -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 = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
return incoming.map((a) => {
@@ -201,6 +249,19 @@ export const applyAutoGrades = <T extends AutoGradableAnswer>(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 = <T extends AutoGradableAnswer>(incoming: T[]): T[
})
}
/**
* V3-2: 服务端即时自动批改
*
* 与 applyAutoGrades 类似,但用于学生提交时服务端回写。
* 返回批改结果数组(含 score 和 feedback以及是否全部可自动判分。
*
* @returns { answers: 批改后的答案数组, isFullyAutoGraded: 是否全部题目可自动判分 }
*/
export const autoGradeSubmission = <T extends AutoGradableAnswer>(
incoming: T[]
): { answers: Array<T & { score: number }>; isFullyAutoGraded: boolean } => {
const graded: Array<T & { score: number }> = []
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,
}
}
/**
* 格式化学生答案为可读字符串
*/

View File

@@ -1,18 +1,51 @@
import { z } from "zod"
export const CreateHomeworkAssignmentSchema = z.object({
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: z.string().optional(),
dueAt: z.string().optional(),
availableAt: dateStringSchema.optional(),
dueAt: dateStringSchema.optional(),
allowLate: z.coerce.boolean().optional(),
lateDueAt: z.string().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<typeof CreateHomeworkAssignmentSchema>

View File

@@ -115,17 +115,16 @@ export const getHomeworkAssignmentAnalytics = cache(
if (!assignment) return null
const [targetsRow] = await db
const [targetsRows, submissionsRows, submittedRows, gradedRows, assignmentQuestions] = await Promise.all([
db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
const [submissionsRow] = await db
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId)),
db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
const [submittedRow] = await db
.where(eq(homeworkSubmissions.assignmentId, assignmentId)),
db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
@@ -133,18 +132,17 @@ export const getHomeworkAssignmentAnalytics = cache(
eq(homeworkSubmissions.assignmentId, assignmentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
const [gradedRow] = await db
),
db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
.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<string, HomeworkAssignmentQuestionAnalytics>()
@@ -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(),
},

View File

@@ -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 ?? []
@@ -179,20 +167,6 @@ export function AppSidebar({ mode }: AppSidebarProps) {
{/* Sidebar Footer */}
<div className="p-4">
{availableRoles.length > 1 && (expanded || isMobile) && (
<div className="px-2 pb-2">
<Select value={effectiveRole} onValueChange={(v) => setCurrentRole(v as Role)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="切换角色" />
</SelectTrigger>
<SelectContent>
{availableRoles.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{!isMobile && (
<button
onClick={toggleSidebar}

View File

@@ -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<SidebarContextType | undefined>(
@@ -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<Role | null>(null)
React.useEffect(() => {
const checkMobile = () => {
@@ -67,7 +62,7 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
return (
<SidebarContext.Provider
value={{ expanded, setExpanded, isMobile, toggleSidebar, currentRole, setCurrentRole }}
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
>
<div className="flex h-screen overflow-hidden w-full flex-col md:flex-row bg-background">
{/* Mobile Trigger & Sheet */}

View File

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

View File

@@ -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<Record<Role, NavItem[]>> = {
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<Record<Role, NavItem[]>> = {
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<Record<Role, NavItem[]>> = {
{ 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<Record<Role, NavItem[]>> = {
{ 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<Record<Role, NavItem[]>> = {
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<Record<Role, NavItem[]>> = {
href: "/parent/attendance",
permission: Permissions.ATTENDANCE_READ,
},
{
title: "错题本",
icon: BookX,
href: "/parent/error-book",
permission: Permissions.ERROR_BOOK_READ,
},
{
title: "Leave Request",
icon: CalendarRange,

View File

@@ -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<ProctoringDashboardData>(error.message)
}
console.error("getProctoringDashboardAction error:", error)
return failState<ProctoringDashboardData>("Failed to load proctoring dashboard")
return handleActionError(error)
}
}

View File

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

View File

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

View File

@@ -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<ReturnType<typeof getQuestions>>
@@ -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<unknown>(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<unknown>(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)
}
}

View File

@@ -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<KnowledgePointOption[]>([])
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
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<QuestionFormValues>({
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({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
<SelectField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Question Type</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
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" },
]}
/>
<FormField
<SelectField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty (1-5)</FormLabel>
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(parseInt(val))}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
</FormControl>
<SelectContent>
{[1, 2, 3, 4, 5].map((level) => (
<SelectItem key={level} value={String(level)}>
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
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"}`,
}))}
/>
</div>
<FormField
<TextareaField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Question Content</FormLabel>
<FormControl>
<Textarea
label="Question Content"
placeholder="Enter the question text here..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
Supports basic text. Rich text editor coming soon.
</FormDescription>
<FormMessage />
</FormItem>
)}
description="Supports basic text. Rich text editor coming soon."
textareaClassName="min-h-[100px]"
/>
<div className="space-y-3">
@@ -444,7 +388,7 @@ export function CreateQuestionDialog({
<div className="space-y-2">
{form.watch("options")?.map((option, index) => (
<div key={option.value || index} className="flex items-center gap-2">
<div key={option.value || `option-${index}`} className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>

View File

@@ -48,15 +48,20 @@ export function QuestionActions({ question }: QuestionActionsProps) {
const [isDeleting, setIsDeleting] = useState(false)
const copyId = () => {
try {
navigator.clipboard.writeText(question.id)
toast.success("Question ID copied to clipboard")
} catch (e) {
console.error("Failed to copy question ID to clipboard", e)
toast.error("Failed to copy question ID")
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
const fd = new FormData()
fd.set("id", question.id)
fd.set("questionId", question.id)
const res = await deleteQuestionAction(undefined, fd)
if (res.success) {
toast.success("Question deleted successfully")
@@ -65,7 +70,8 @@ export function QuestionActions({ question }: QuestionActionsProps) {
} else {
toast.error(res.message || "Failed to delete question")
}
} catch {
} catch (e) {
console.error("Failed to delete question", e)
toast.error("Failed to delete question")
} finally {
setIsDeleting(false)

View File

@@ -4,41 +4,12 @@ import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Question, QuestionType } from "../types"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils"
import { Question } from "../types"
import { QUESTION_TYPE_VARIANT, QUESTION_TYPE_LABEL } from "../types"
import { QuestionActions } from "./question-actions"
const getTypeColor = (type: QuestionType) => {
switch (type) {
case "single_choice":
return "default"
case "multiple_choice":
return "secondary"
case "judgment":
return "outline"
case "text":
return "secondary"
default:
return "secondary"
}
}
const getTypeLabel = (type: QuestionType) => {
switch (type) {
case "single_choice":
return "Single Choice"
case "multiple_choice":
return "Multiple Choice"
case "judgment":
return "True/False"
case "text":
return "Short Answer"
case "composite":
return "Composite"
default:
return type
}
}
export const columns: ColumnDef<Question>[] = [
{
id: "select",
@@ -63,11 +34,15 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "type",
header: "Type",
cell: ({ row }) => {
const type = row.getValue("type") as QuestionType
const type = row.original.type
return (
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
{getTypeLabel(type)}
</Badge>
<StatusBadge
status={type}
variantMap={QUESTION_TYPE_VARIANT}
labelMap={QUESTION_TYPE_LABEL}
className="whitespace-nowrap"
capitalize={false}
/>
)
},
},
@@ -75,7 +50,7 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "content",
header: "Content",
cell: ({ row }) => {
const content = row.getValue("content") as unknown
const content = row.original.content
let preview = ""
if (typeof content === "string") {
preview = content
@@ -100,7 +75,7 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "difficulty",
header: "Difficulty",
cell: ({ row }) => {
const diff = row.getValue("difficulty") as number
const diff = row.original.difficulty
const label =
diff === 1
? "Easy"
@@ -148,9 +123,14 @@ export const columns: ColumnDef<Question>[] = [
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
const createdAt = row.original.createdAt
return (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.getValue("createdAt")).toLocaleDateString()}
{createdAt instanceof Date
? formatDate(createdAt)
: typeof createdAt === "string"
? formatDate(createdAt)
: "—"}
</span>
)
},

View File

@@ -37,7 +37,9 @@ export const getQuestions = cache(async ({
type,
difficulty,
}: GetQuestionsParams = {}) => {
const offset = (page - 1) * pageSize;
const safePage = typeof page === "number" && page >= 1 ? page : 1
const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50
const offset = (safePage - 1) * safePageSize;
const conditions: SQL[] = [];
@@ -84,7 +86,7 @@ export const getQuestions = cache(async ({
const rows = await db.query.questions.findMany({
where: whereClause,
limit: pageSize,
limit: safePageSize,
offset: offset,
orderBy: [desc(questions.createdAt)],
with: {
@@ -132,10 +134,10 @@ export const getQuestions = cache(async ({
return mapped;
}),
meta: {
page,
pageSize,
page: safePage,
pageSize: safePageSize,
total,
totalPages: Math.ceil(total / pageSize),
totalPages: Math.ceil(total / safePageSize),
},
};
});
@@ -229,14 +231,24 @@ export async function updateQuestionById(
});
}
async function deleteQuestionRecursive(tx: Tx, questionId: string): Promise<void> {
async function deleteQuestionRecursive(
tx: Tx,
questionId: string,
visited: Set<string> = new Set(),
): Promise<void> {
if (visited.has(questionId)) {
// 环检测:避免在异常数据(如循环引用)下无限递归
return
}
visited.add(questionId)
const children = await tx
.select({ id: questions.id })
.from(questions)
.where(eq(questions.parentId, questionId));
for (const child of children) {
await deleteQuestionRecursive(tx, child.id);
await deleteQuestionRecursive(tx, child.id, visited);
}
await tx.delete(questions).where(eq(questions.id, questionId));

View File

@@ -1,8 +1,27 @@
import { z } from "zod"
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
import { QuestionTypeEnum } from "./schema"
export type QuestionType = z.infer<typeof QuestionTypeEnum>
/** 题型 → Badge variant 映射 */
export const QUESTION_TYPE_VARIANT: StatusVariantMap<QuestionType> = {
single_choice: "default",
multiple_choice: "secondary",
judgment: "outline",
text: "secondary",
composite: "secondary",
}
/** 题型 → 展示文本映射 */
export const QUESTION_TYPE_LABEL: StatusLabelMap<QuestionType> = {
single_choice: "Single Choice",
multiple_choice: "Multiple Choice",
judgment: "True/False",
text: "Short Answer",
composite: "Composite",
}
export interface Question {
id: string
content: unknown

View File

@@ -21,6 +21,7 @@ export type GradeOption = {
* 新增学科只需在此处添加一条记录,所有 Select/筛选/表单/设置弹窗自动同步。
*/
export const SUBJECTS: readonly SubjectOption[] = [
{ value: "Chinese", labelKey: "chinese" },
{ value: "Mathematics", labelKey: "mathematics" },
{ value: "Physics", labelKey: "physics" },
{ value: "Chemistry", labelKey: "chemistry" },
@@ -34,6 +35,8 @@ export const SUBJECTS: readonly SubjectOption[] = [
* 年级列表。
*/
export const GRADES: readonly GradeOption[] = [
{ value: "Grade 1", labelKey: "grade1" },
{ value: "Grade 2", labelKey: "grade2" },
{ value: "Grade 7", labelKey: "grade7" },
{ value: "Grade 8", labelKey: "grade8" },
{ value: "Grade 9", labelKey: "grade9" },
@@ -47,6 +50,8 @@ export const GRADES: readonly GradeOption[] = [
* key 必须与 SUBJECTS 中的 value 一致。
*/
export const SUBJECT_COLORS: Record<string, string> = {
Chinese:
"bg-rose-50 text-rose-700 border-rose-200/70 dark:bg-rose-950/50 dark:text-rose-200 dark:border-rose-900/60",
Mathematics:
"bg-blue-50 text-blue-700 border-blue-200/70 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/60",
Physics:
@@ -73,3 +78,19 @@ export const DEFAULT_SUBJECT_COLOR =
export function getSubjectColor(subject: string): string {
return SUBJECT_COLORS[subject] ?? DEFAULT_SUBJECT_COLOR
}
/**
* 根据学科 value 获取 i18n labelKey。
* 未命中时返回 value 本身(作为兜底,调用方可直接用作显示文本)。
*/
export function getSubjectLabelKey(subject: string): string {
return SUBJECTS.find((s) => s.value === subject)?.labelKey ?? subject
}
/**
* 根据年级 value 获取 i18n labelKey。
* 未命中时返回 value 本身。
*/
export function getGradeLabelKey(grade: string): string {
return GRADES.find((g) => g.value === grade)?.labelKey ?? grade
}

View File

@@ -92,6 +92,22 @@ export async function updateUserProfileById(
return await getUserProfile(userId)
}
/**
* Update a user's avatar image URL.
* Returns the updated user profile or null if the user was not found.
*/
export async function updateUserAvatar(
userId: string,
imageUrl: string | null
): Promise<UserProfile | null> {
await db
.update(users)
.set({ image: imageUrl })
.where(eq(users.id, userId))
return await getUserProfile(userId)
}
export type UsersDashboardStats = {
userCount: number
activeSessionsCount: number