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:
@@ -43,14 +43,19 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
setIsDeleting(true)
|
||||
const result = await deleteAttendanceAction(deleteId)
|
||||
setIsDeleting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("sheet.deleted"))
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
try {
|
||||
const result = await deleteAttendanceAction(deleteId)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("sheet.deleted"))
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,12 +72,16 @@ export function AttendanceRulesForm({
|
||||
formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
|
||||
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
|
||||
|
||||
const result = await saveAttendanceRulesAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("rules.saved"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
try {
|
||||
const result = await saveAttendanceRulesAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("rules.saved"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -210,14 +210,19 @@ export function AttendanceSheet({
|
||||
setIsSubmitting(true)
|
||||
formData.set("recordsJson", JSON.stringify(records))
|
||||
|
||||
const result = await batchRecordAttendanceAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("sheet.saved"))
|
||||
router.push("/teacher/attendance")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
try {
|
||||
const result = await batchRecordAttendanceAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("sheet.saved"))
|
||||
router.push("/teacher/attendance")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("errors.unexpected"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,47 +76,91 @@ 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">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-muted-foreground hover:underline"
|
||||
{!requiresTwoFactor ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-muted-foreground hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
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}
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
← Back to login
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
@@ -70,25 +70,31 @@ export function AdminClassesClient({
|
||||
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
|
||||
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateSchoolId(defaultSchoolId)
|
||||
setCreateGradeId("")
|
||||
}, [createOpen, defaultTeacherId, defaultSchoolId])
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateSchoolId(defaultSchoolId)
|
||||
setCreateGradeId("")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editItem) return
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditSchoolId(editItem.schoolId ?? "")
|
||||
setEditGradeId(editItem.gradeId ?? "")
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
}))
|
||||
)
|
||||
}, [editItem])
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditSchoolId(editItem.schoolId ?? "")
|
||||
setEditGradeId(editItem.gradeId ?? "")
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Plus } from "lucide-react"
|
||||
@@ -38,12 +38,15 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
|
||||
const [createClassId, setCreateClassId] = useState(defaultClassId)
|
||||
const [weekday, setWeekday] = useState<string>("1")
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCreateClassId(defaultClassId)
|
||||
setWeekday("1")
|
||||
}, [open, defaultClassId])
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setCreateClassId(defaultClassId)
|
||||
setWeekday("1")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -74,16 +74,22 @@ export function ScheduleView({
|
||||
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
|
||||
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editItem) return
|
||||
setEditClassId(editItem.classId)
|
||||
setEditWeekday(String(editItem.weekday))
|
||||
}, [editItem])
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditClassId(editItem.classId)
|
||||
setEditWeekday(String(editItem.weekday))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setCreateClassId(defaultClassId)
|
||||
}, [createOpen, defaultClassId])
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateClassId(defaultClassId)
|
||||
}
|
||||
}
|
||||
|
||||
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
||||
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
||||
@@ -49,10 +49,13 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
|
||||
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
}, [open, effectiveClassId, classes])
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnroll = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||
import { cn, getInitials } from "@/shared/lib/utils"
|
||||
import { cn, formatDate, getInitials } from "@/shared/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,16 +16,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||
import type { ClassStudent } from "../types"
|
||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||
|
||||
@@ -79,11 +70,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
{s.className}
|
||||
</span>
|
||||
<span className="text-[10px]">
|
||||
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit"
|
||||
})}
|
||||
{formatDate(s.joinedAt, "en-GB")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,41 +148,29 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
<ConfirmDeleteDialog
|
||||
open={Boolean(removeTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (workingKey !== null) return
|
||||
if (!open) setRemoveTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove student from class?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{removeTarget ? (
|
||||
<>
|
||||
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
|
||||
<span className="font-medium text-foreground">{removeTarget.className}</span>.
|
||||
</>
|
||||
) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={workingKey !== null}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={workingKey !== null}
|
||||
onClick={() => {
|
||||
if (!removeTarget) return
|
||||
setRemoveTarget(null)
|
||||
setStatus(removeTarget, "inactive")
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
title="Remove student from class?"
|
||||
confirmText="Remove"
|
||||
description={
|
||||
removeTarget ? (
|
||||
<>
|
||||
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
|
||||
<span className="font-medium text-foreground">{removeTarget.className}</span>.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!removeTarget) return
|
||||
setRemoveTarget(null)
|
||||
setStatus(removeTarget, "inactive")
|
||||
}}
|
||||
isWorking={workingKey !== null}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,13 @@ export const mapWithConcurrency = async <T, R>(
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
try {
|
||||
results[index] = await worker(items[index], index)
|
||||
} catch (error) {
|
||||
// Catch per-item errors so a single failure doesn't reject the whole batch.
|
||||
// The result slot stays undefined; callers should handle missing entries.
|
||||
console.error("[mapWithConcurrency] worker error at index", index, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
|
||||
type ChoiceOption = {
|
||||
@@ -21,23 +22,41 @@ type ExamPaperPreviewProps = {
|
||||
nodes: ExamNode[]
|
||||
}
|
||||
|
||||
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
|
||||
// Helper to flatten questions for continuous numbering
|
||||
let questionCounter = 0
|
||||
const parseContent = (raw: unknown): QuestionContent => {
|
||||
if (raw && typeof raw === "object") return raw as QuestionContent
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed && typeof parsed === "object") return parsed as QuestionContent
|
||||
return { text: raw }
|
||||
} catch {
|
||||
return { text: raw }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const parseContent = (raw: unknown): QuestionContent => {
|
||||
if (raw && typeof raw === "object") return raw as QuestionContent
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed && typeof parsed === "object") return parsed as QuestionContent
|
||||
return { text: raw }
|
||||
} catch {
|
||||
return { text: raw }
|
||||
// Precompute question numbers as a Map to avoid mutating a counter during render
|
||||
const buildQuestionNumberMap = (nodes: ExamNode[]): Map<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)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
walk(nodes)
|
||||
return map
|
||||
}
|
||||
|
||||
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
|
||||
// Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment.
|
||||
const numberMap = useMemo(() => buildQuestionNumberMap(nodes), [nodes])
|
||||
|
||||
const renderNode = (node: ExamNode, depth: number = 0) => {
|
||||
if (node.type === 'group') {
|
||||
@@ -57,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 ?? ""}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
if (node.id === id) return node
|
||||
if (node.children) {
|
||||
const found = findItem(id, node.children)
|
||||
if (found) return found
|
||||
const findItem = useCallback((id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
||||
const walk = (list: ExamNode[]): ExamNode | null => {
|
||||
for (const node of list) {
|
||||
if (node.id === id) return node
|
||||
if (node.children) {
|
||||
const found = walk(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(nodes)
|
||||
}, [items])
|
||||
|
||||
const activeItem = activeId ? findItem(activeId) : null
|
||||
|
||||
// DND Handlers
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Custom collision detection for nested sortables
|
||||
const customCollisionDetection: CollisionDetection = (args) => {
|
||||
const customCollisionDetection: CollisionDetection = useCallback((args) => {
|
||||
// 1. First check pointer within for precise container detection
|
||||
const pointerCollisions = pointerWithin(args)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 || "未命名题目"}
|
||||
|
||||
@@ -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 } }
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createId } from "@paralleldrive/cuid2"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/data-access"
|
||||
import { escapeLikePattern } from "@/shared/lib/action-utils"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -64,7 +65,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
const conditions = []
|
||||
|
||||
if (params.q) {
|
||||
const search = `%${params.q}%`
|
||||
const search = `%${escapeLikePattern(params.q)}%`
|
||||
conditions.push(or(like(exams.title, search), like(exams.description, search)))
|
||||
}
|
||||
|
||||
@@ -82,10 +83,19 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
} else {
|
||||
// P0 fix: empty grade set must NOT bypass filtering (would expose all exams)
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
} else if (params.scope.type === "class_taught") {
|
||||
// P0 fix: class_taught scope with no classIds must return nothing
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
} else if (params.scope.type === "grade_managed") {
|
||||
// P0 fix: grade_managed scope with no gradeIds must return nothing
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
// "all" type: no filtering
|
||||
// "class_members": student sees published exams for their grade (would need student's gradeId)
|
||||
@@ -126,8 +136,10 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
})
|
||||
|
||||
if (params.difficulty && params.difficulty !== "all") {
|
||||
const d = parseInt(params.difficulty)
|
||||
result = result.filter((e) => e.difficulty === d)
|
||||
const d = parseInt(params.difficulty, 10)
|
||||
if (!Number.isNaN(d)) {
|
||||
result = result.filter((e) => e.difficulty === d)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -155,13 +167,20 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
if (scope.type === "grade_managed") {
|
||||
// P0 fix: empty gradeIds must NOT bypass filtering (would leak exam details)
|
||||
if (scope.gradeIds.length === 0) return null
|
||||
if (!scope.gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
if (scope.type === "class_taught") {
|
||||
// P0 fix: empty classIds must NOT bypass filtering (would leak exam details)
|
||||
if (scope.classIds.length === 0) return null
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
|
||||
if (gradeIds.length === 0) return null
|
||||
if (!gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -182,7 +201,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
updatedAt: exam.updatedAt?.toISOString(),
|
||||
tags: getStringArray(meta, "tags") || [],
|
||||
structure: exam.structure as unknown,
|
||||
structure: exam.structure,
|
||||
questions: exam.questions.map((eqRel) => ({
|
||||
id: eqRel.questionId,
|
||||
score: eqRel.score ?? 0,
|
||||
@@ -379,14 +398,26 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
||||
if (scope.type === "owned") {
|
||||
conditions.push(eq(exams.creatorId, scope.userId))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
if (scope.type === "grade_managed") {
|
||||
// P0 fix: empty gradeIds must NOT bypass filtering
|
||||
if (scope.gradeIds.length === 0) {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
} else {
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
if (scope.type === "class_taught") {
|
||||
// P0 fix: empty classIds must NOT bypass filtering
|
||||
if (scope.classIds.length === 0) {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
} else {
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
} else {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 列表批量查询文件(用于批量删除前获取磁盘路径)
|
||||
*/
|
||||
|
||||
@@ -72,13 +72,18 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
|
||||
formData.set("publish", "true")
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || t("homework.form.createFailed"))
|
||||
try {
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || t("homework.form.createFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.form.createFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,27 +70,40 @@ export function useDebouncedAutoSave({
|
||||
savingRef.current = true
|
||||
setStatus("saving")
|
||||
|
||||
let allOk = true
|
||||
for (const [questionId, answer] of pending) {
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
// 并行保存所有待保存答案,单个失败不影响其他答案
|
||||
const results = await Promise.allSettled(
|
||||
pending.map(([questionId, answer]) => {
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer }))
|
||||
return saveHomeworkAnswerAction(null, fd)
|
||||
})
|
||||
)
|
||||
|
||||
savingRef.current = false
|
||||
|
||||
if (allOk) {
|
||||
// 收集失败的 questionId 以便重试
|
||||
const failedQuestionIds: string[] = []
|
||||
results.forEach((res, idx) => {
|
||||
if (res.status !== "fulfilled" || !res.value.success) {
|
||||
failedQuestionIds.push(pending[idx][0])
|
||||
}
|
||||
})
|
||||
|
||||
if (failedQuestionIds.length === 0) {
|
||||
pendingRef.current.clear()
|
||||
setStatus("saved")
|
||||
setLastSavedAt(Date.now())
|
||||
} else {
|
||||
setStatus("error")
|
||||
// Keep pending items for retry on next change or manual flush
|
||||
// 仅保留失败的项用于重试,移除已成功的项
|
||||
const newPending = new Map<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])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生答案为可读字符串
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z.object({
|
||||
sourceExamId: z.string().optional(),
|
||||
classId: z.string().min(1),
|
||||
title: z.string().min(1, "Title is required for quick assignments"),
|
||||
description: z.string().optional(),
|
||||
availableAt: z.string().optional(),
|
||||
dueAt: z.string().optional(),
|
||||
allowLate: z.coerce.boolean().optional(),
|
||||
lateDueAt: z.string().optional(),
|
||||
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
|
||||
targetStudentIds: z.array(z.string().min(1)).optional(),
|
||||
publish: z.coerce.boolean().optional(),
|
||||
})
|
||||
const dateStringSchema = z
|
||||
.string()
|
||||
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format")
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z
|
||||
.object({
|
||||
sourceExamId: z.string().optional(),
|
||||
classId: z.string().min(1),
|
||||
title: z.string().min(1, "Title is required for quick assignments"),
|
||||
description: z.string().optional(),
|
||||
availableAt: dateStringSchema.optional(),
|
||||
dueAt: dateStringSchema.optional(),
|
||||
allowLate: z.coerce.boolean().optional(),
|
||||
lateDueAt: dateStringSchema.optional(),
|
||||
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
|
||||
targetStudentIds: z.array(z.string().min(1)).optional(),
|
||||
publish: z.coerce.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// 时序校验:availableAt < dueAt < lateDueAt
|
||||
const available = data.availableAt ? new Date(data.availableAt).getTime() : null
|
||||
const due = data.dueAt ? new Date(data.dueAt).getTime() : null
|
||||
const lateDue = data.lateDueAt ? new Date(data.lateDueAt).getTime() : null
|
||||
|
||||
if (available !== null && due !== null && available > due) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["dueAt"],
|
||||
message: "截止时间必须晚于可用时间",
|
||||
})
|
||||
}
|
||||
if (due !== null && lateDue !== null && due > lateDue) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["lateDueAt"],
|
||||
message: "迟交截止时间必须晚于正常截止时间",
|
||||
})
|
||||
}
|
||||
if (data.allowLate && !data.lateDueAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["lateDueAt"],
|
||||
message: "允许迟交时必须设置迟交截止时间",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateHomeworkAssignmentInput = z.infer<typeof CreateHomeworkAssignmentSchema>
|
||||
|
||||
|
||||
@@ -115,36 +115,34 @@ export const getHomeworkAssignmentAnalytics = cache(
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
||||
|
||||
const [submissionsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
||||
|
||||
const [submittedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
)
|
||||
|
||||
const [gradedRow] = await 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: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
})
|
||||
const [targetsRows, submissionsRows, submittedRows, gradedRows, assignmentQuestions] = await Promise.all([
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId)),
|
||||
db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded"))),
|
||||
db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
}),
|
||||
])
|
||||
|
||||
const statsByQuestionId = new Map<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(),
|
||||
},
|
||||
|
||||
@@ -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,26 +167,12 @@ 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}
|
||||
className="hover:bg-sidebar-accent text-sidebar-foreground flex w-full items-center justify-center rounded-md border p-2 text-sm transition-colors"
|
||||
>
|
||||
{expanded ? "收起" : <ChevronRight className="size-4" />}
|
||||
{expanded ? "收起" : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
placeholder="Enter the question text here..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supports basic text. Rich text editor coming soon.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Question Content"
|
||||
placeholder="Enter the question text here..."
|
||||
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>
|
||||
|
||||
@@ -48,15 +48,20 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success("Question ID copied to clipboard")
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user