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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteId) return
|
if (!deleteId) return
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
const result = await deleteAttendanceAction(deleteId)
|
try {
|
||||||
setIsDeleting(false)
|
const result = await deleteAttendanceAction(deleteId)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message || t("sheet.deleted"))
|
toast.success(result.message || t("sheet.deleted"))
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || t("errors.unexpected"))
|
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("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
|
||||||
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
|
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
|
||||||
|
|
||||||
const result = await saveAttendanceRulesAction(null, formData)
|
try {
|
||||||
if (result.success) {
|
const result = await saveAttendanceRulesAction(null, formData)
|
||||||
toast.success(result.message || t("rules.saved"))
|
if (result.success) {
|
||||||
router.refresh()
|
toast.success(result.message || t("rules.saved"))
|
||||||
} else {
|
router.refresh()
|
||||||
toast.error(result.message || t("errors.unexpected"))
|
} else {
|
||||||
|
toast.error(result.message || t("errors.unexpected"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,14 +210,19 @@ export function AttendanceSheet({
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
formData.set("recordsJson", JSON.stringify(records))
|
formData.set("recordsJson", JSON.stringify(records))
|
||||||
|
|
||||||
const result = await batchRecordAttendanceAction(null, formData)
|
try {
|
||||||
setIsSubmitting(false)
|
const result = await batchRecordAttendanceAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message || t("sheet.saved"))
|
toast.success(result.message || t("sheet.saved"))
|
||||||
router.push("/teacher/attendance")
|
router.push("/teacher/attendance")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || t("errors.unexpected"))
|
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 { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
interface AttendanceStatsCardsProps {
|
interface AttendanceStatsCardsProps {
|
||||||
stats: {
|
stats: {
|
||||||
@@ -68,8 +69,8 @@ export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
|||||||
<Card key={card.title} className="shadow-none">
|
<Card key={card.title} className="shadow-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded-md ${card.bgColor}`}>
|
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
|
||||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
<card.icon className={cn("h-4 w-4", card.color)} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
||||||
|
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -96,9 +97,9 @@ export async function getAttendanceRecords(
|
|||||||
}
|
}
|
||||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||||
if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId))
|
if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId))
|
||||||
if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date)))
|
if (params.date) conditions.push(eq(attendanceRecords.date, safeParseDate(params.date, "日期")))
|
||||||
if (params.startDate) conditions.push(gte(attendanceRecords.date, new Date(params.startDate)))
|
if (params.startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(params.startDate, "开始日期")))
|
||||||
if (params.endDate) conditions.push(lte(attendanceRecords.date, new Date(params.endDate)))
|
if (params.endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(params.endDate, "结束日期")))
|
||||||
if (params.status) conditions.push(eq(attendanceRecords.status, params.status))
|
if (params.status) conditions.push(eq(attendanceRecords.status, params.status))
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
@@ -163,7 +164,7 @@ export async function createAttendanceRecord(
|
|||||||
studentId: data.studentId,
|
studentId: data.studentId,
|
||||||
classId: data.classId,
|
classId: data.classId,
|
||||||
scheduleId: data.scheduleId ?? null,
|
scheduleId: data.scheduleId ?? null,
|
||||||
date: new Date(data.date),
|
date: safeParseDate(data.date, "日期"),
|
||||||
status: data.status,
|
status: data.status,
|
||||||
remark: data.remark ?? null,
|
remark: data.remark ?? null,
|
||||||
recordedBy,
|
recordedBy,
|
||||||
@@ -181,7 +182,7 @@ export async function batchCreateAttendanceRecords(
|
|||||||
studentId: r.studentId,
|
studentId: r.studentId,
|
||||||
classId: r.classId,
|
classId: r.classId,
|
||||||
scheduleId: r.scheduleId ?? null,
|
scheduleId: r.scheduleId ?? null,
|
||||||
date: new Date(r.date),
|
date: safeParseDate(r.date, "日期"),
|
||||||
status: r.status,
|
status: r.status,
|
||||||
remark: r.remark ?? null,
|
remark: r.remark ?? null,
|
||||||
recordedBy,
|
recordedBy,
|
||||||
@@ -304,7 +305,7 @@ export async function getAttendanceStats(params: {
|
|||||||
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
||||||
}
|
}
|
||||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
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
|
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { EmptyTableRow } from "@/shared/components/ui/empty-table-row"
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
import { Pagination } from "@/shared/components/ui/pagination"
|
||||||
|
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { AuditLog } from "../types"
|
import type { AuditLog } from "../types"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
||||||
|
|
||||||
interface AuditLogTableProps {
|
interface AuditLogTableProps {
|
||||||
items: AuditLog[]
|
items: AuditLog[]
|
||||||
@@ -32,9 +33,6 @@ export function AuditLogTable({
|
|||||||
totalPages,
|
totalPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: AuditLogTableProps) {
|
}: AuditLogTableProps) {
|
||||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
||||||
const end = Math.min(page * pageSize, total)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@@ -52,11 +50,7 @@ export function AuditLogTable({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<EmptyTableRow colSpan={7} message="No audit logs found." />
|
||||||
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
|
||||||
No audit logs found.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
items.map((log) => (
|
items.map((log) => (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
@@ -85,7 +79,11 @@ export function AuditLogTable({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={log.status} />
|
<StatusBadge
|
||||||
|
status={log.status}
|
||||||
|
variantMap={AUDIT_STATUS_VARIANT}
|
||||||
|
classNameMap={AUDIT_STATUS_CLASS_NAME}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
{log.ipAddress ?? "-"}
|
{log.ipAddress ?? "-"}
|
||||||
@@ -100,57 +98,13 @@ export function AuditLogTable({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-2 py-4">
|
<Pagination
|
||||||
<div className="text-sm text-muted-foreground">
|
page={page}
|
||||||
{total > 0 ? (
|
pageSize={pageSize}
|
||||||
<>
|
total={total}
|
||||||
Showing <span className="font-medium">{start}</span>-
|
totalPages={totalPages}
|
||||||
<span className="font-medium">{end}</span> of{" "}
|
onPageChange={onPageChange}
|
||||||
<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>
|
|
||||||
</div>
|
</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 { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,10 +25,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
import type { DataChangeLog, DataChangeStat } from "../types"
|
import type { DataChangeLog, DataChangeStat } from "../types"
|
||||||
|
import {
|
||||||
|
DATA_CHANGE_ACTION_VARIANT,
|
||||||
|
DATA_CHANGE_ACTION_CLASS_NAME,
|
||||||
|
} from "../types"
|
||||||
|
|
||||||
interface DataChangeLogTableProps {
|
interface DataChangeLogTableProps {
|
||||||
items: DataChangeLog[]
|
items: DataChangeLog[]
|
||||||
@@ -61,9 +66,6 @@ function DataChangeLogTableInner({
|
|||||||
router.push(query ? `?${query}` : "?")
|
router.push(query ? `?${query}` : "?")
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
||||||
const end = Math.min(page * pageSize, total)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
|
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
|
||||||
@@ -83,11 +85,7 @@ function DataChangeLogTableInner({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<EmptyTableRow colSpan={7} message="No data change logs found." />
|
||||||
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
|
||||||
No data change logs found.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
items.map((log) => (
|
items.map((log) => (
|
||||||
<Fragment key={log.id}>
|
<Fragment key={log.id}>
|
||||||
@@ -99,7 +97,11 @@ function DataChangeLogTableInner({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{log.recordId}</TableCell>
|
<TableCell className="font-mono text-xs">{log.recordId}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ActionBadge action={log.action} />
|
<StatusBadge
|
||||||
|
status={log.action}
|
||||||
|
variantMap={DATA_CHANGE_ACTION_VARIANT}
|
||||||
|
classNameMap={DATA_CHANGE_ACTION_CLASS_NAME}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -154,42 +156,13 @@ function DataChangeLogTableInner({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-2 py-4">
|
<Pagination
|
||||||
<div className="text-sm text-muted-foreground">
|
page={page}
|
||||||
{total > 0 ? (
|
pageSize={pageSize}
|
||||||
<>
|
total={total}
|
||||||
Showing <span className="font-medium">{start}</span>-
|
totalPages={totalPages}
|
||||||
<span className="font-medium">{end}</span> of{" "}
|
onPageChange={handlePageChange}
|
||||||
<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>
|
|
||||||
</div>
|
</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) {
|
export function DataChangeLogTable(props: DataChangeLogTableProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { EmptyTableRow } from "@/shared/components/ui/empty-table-row"
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
import { Pagination } from "@/shared/components/ui/pagination"
|
||||||
|
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { LoginLog } from "../types"
|
import type { LoginLog } from "../types"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
||||||
|
|
||||||
interface LoginLogTableProps {
|
interface LoginLogTableProps {
|
||||||
items: LoginLog[]
|
items: LoginLog[]
|
||||||
@@ -32,9 +33,6 @@ export function LoginLogTable({
|
|||||||
totalPages,
|
totalPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: LoginLogTableProps) {
|
}: LoginLogTableProps) {
|
||||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
||||||
const end = Math.min(page * pageSize, total)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@@ -51,11 +49,7 @@ export function LoginLogTable({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<EmptyTableRow colSpan={6} message="No login logs found." />
|
||||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
|
||||||
No login logs found.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
items.map((log) => (
|
items.map((log) => (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
@@ -73,7 +67,11 @@ export function LoginLogTable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={log.status} />
|
<StatusBadge
|
||||||
|
status={log.status}
|
||||||
|
variantMap={AUDIT_STATUS_VARIANT}
|
||||||
|
classNameMap={AUDIT_STATUS_CLASS_NAME}
|
||||||
|
/>
|
||||||
{log.errorMessage && (
|
{log.errorMessage && (
|
||||||
<div className="mt-1 text-xs text-destructive">{log.errorMessage}</div>
|
<div className="mt-1 text-xs text-destructive">{log.errorMessage}</div>
|
||||||
)}
|
)}
|
||||||
@@ -94,57 +92,13 @@ export function LoginLogTable({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-2 py-4">
|
<Pagination
|
||||||
<div className="text-sm text-muted-foreground">
|
page={page}
|
||||||
{total > 0 ? (
|
pageSize={pageSize}
|
||||||
<>
|
total={total}
|
||||||
Showing <span className="font-medium">{start}</span>-
|
totalPages={totalPages}
|
||||||
<span className="font-medium">{end}</span> of{" "}
|
onPageChange={onPageChange}
|
||||||
<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>
|
|
||||||
</div>
|
</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 AuditLogStatus = "success" | "failure"
|
||||||
|
|
||||||
export type LoginLogAction = "signin" | "signout" | "signup"
|
export type LoginLogAction = "signin" | "signout" | "signup"
|
||||||
@@ -5,6 +7,30 @@ export type LoginLogStatus = "success" | "failure"
|
|||||||
|
|
||||||
export type DataChangeAction = "create" | "update" | "delete"
|
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 {
|
export interface AuditLog {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@@ -8,18 +8,23 @@ import { Button } from "@/shared/components/ui/button"
|
|||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { cn } from "@/shared/lib/utils"
|
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>
|
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
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 router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
async function onSubmit(event: React.SyntheticEvent) {
|
async function onSubmit(event: React.SyntheticEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
const form = event.currentTarget as HTMLFormElement
|
const form = event.currentTarget as HTMLFormElement
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
@@ -27,10 +32,25 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
const password = String(formData.get("password") ?? "")
|
const password = String(formData.get("password") ?? "")
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
|
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", {
|
const result = await signIn("credentials", {
|
||||||
redirect: false,
|
redirect: false,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
totpCode: requiresTwoFactor ? totpCode : undefined,
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,6 +59,13 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
if (!result?.error) {
|
if (!result?.error) {
|
||||||
router.push(result?.url ?? callbackUrl)
|
router.push(result?.url ?? callbackUrl)
|
||||||
router.refresh()
|
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
|
Welcome back
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
{!requiresTwoFactor ? (
|
||||||
<Label htmlFor="email">Email</Label>
|
<>
|
||||||
<Input
|
<div className="grid gap-2">
|
||||||
id="email"
|
<Label htmlFor="email">Email</Label>
|
||||||
name="email"
|
<Input
|
||||||
placeholder="name@example.com"
|
id="email"
|
||||||
type="email"
|
name="email"
|
||||||
autoCapitalize="none"
|
placeholder="name@example.com"
|
||||||
autoComplete="email"
|
type="email"
|
||||||
autoCorrect="off"
|
autoCapitalize="none"
|
||||||
disabled={isLoading}
|
autoComplete="email"
|
||||||
/>
|
autoCorrect="off"
|
||||||
</div>
|
disabled={isLoading}
|
||||||
<div className="grid gap-2">
|
/>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<Label htmlFor="password">Password</Label>
|
<div className="grid gap-2">
|
||||||
<Link
|
<div className="flex items-center justify-between">
|
||||||
href="/forgot-password"
|
<Label htmlFor="password">Password</Label>
|
||||||
className="text-sm font-medium text-muted-foreground hover:underline"
|
<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?
|
← Back to login
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
)}
|
||||||
id="password"
|
{error ? (
|
||||||
name="password"
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
type="password"
|
) : null}
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button disabled={isLoading}>
|
<Button disabled={isLoading}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
Sign In with Email
|
{requiresTwoFactor ? "Verify & Sign In" : "Sign In with Email"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
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 { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
import {
|
import {
|
||||||
createClassScheduleItem,
|
createClassScheduleItem,
|
||||||
updateClassScheduleItem,
|
updateClassScheduleItem,
|
||||||
@@ -50,11 +51,10 @@ export async function createClassScheduleItemAction(
|
|||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Schedule item created successfully", data: id }
|
return { success: true, message: "Schedule item created successfully", data: id }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +93,10 @@ export async function updateClassScheduleItemAction(
|
|||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Schedule item updated successfully" }
|
return { success: true, message: "Schedule item updated successfully" }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +114,9 @@ export async function deleteClassScheduleItemAction(scheduleId: string): Promise
|
|||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Schedule item deleted successfully" }
|
return { success: true, message: "Schedule item deleted successfully" }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
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 { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
import {
|
import {
|
||||||
createTeacherClass,
|
createTeacherClass,
|
||||||
deleteTeacherClass,
|
deleteTeacherClass,
|
||||||
@@ -68,11 +69,10 @@ export async function createTeacherClassAction(
|
|||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class created successfully", data: id }
|
return { success: true, message: "Class created successfully", data: id }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,11 +115,10 @@ export async function updateTeacherClassAction(
|
|||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class updated successfully" }
|
return { success: true, message: "Class updated successfully" }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +138,9 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
|||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class deleted successfully" }
|
return { success: true, message: "Class deleted successfully" }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
@@ -70,25 +70,31 @@ export function AdminClassesClient({
|
|||||||
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
|
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
|
||||||
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
|
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||||
if (!createOpen) return
|
if (createOpen !== prevCreateOpen) {
|
||||||
setCreateTeacherId(defaultTeacherId)
|
setPrevCreateOpen(createOpen)
|
||||||
setCreateSchoolId(defaultSchoolId)
|
if (createOpen) {
|
||||||
setCreateGradeId("")
|
setCreateTeacherId(defaultTeacherId)
|
||||||
}, [createOpen, defaultTeacherId, defaultSchoolId])
|
setCreateSchoolId(defaultSchoolId)
|
||||||
|
setCreateGradeId("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||||
if (!editItem) return
|
if (editItem !== prevEditItem) {
|
||||||
setEditTeacherId(editItem.teacher.id)
|
setPrevEditItem(editItem)
|
||||||
setEditSchoolId(editItem.schoolId ?? "")
|
if (editItem) {
|
||||||
setEditGradeId(editItem.gradeId ?? "")
|
setEditTeacherId(editItem.teacher.id)
|
||||||
setEditSubjectTeachers(
|
setEditSchoolId(editItem.schoolId ?? "")
|
||||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
setEditGradeId(editItem.gradeId ?? "")
|
||||||
subject: s,
|
setEditSubjectTeachers(
|
||||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||||
}))
|
subject: s,
|
||||||
)
|
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||||
}, [editItem])
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
const handleCreate = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function ClassInvitationManager({
|
|||||||
} else {
|
} else {
|
||||||
toast.error(result.message ?? t("revokeFailed"))
|
toast.error(result.message ?? t("revokeFailed"))
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("revokeFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -258,6 +260,8 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
|
|||||||
} else {
|
} else {
|
||||||
toast.error(result.message ?? t("generateFailed"))
|
toast.error(result.message ?? t("generateFailed"))
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("generateFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,16 +23,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import {
|
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
@@ -431,25 +422,16 @@ export function GradeClassesClient({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<AlertDialog
|
<ConfirmDeleteDialog
|
||||||
open={Boolean(deleteItem)}
|
open={Boolean(deleteItem)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDeleteItem(null)
|
if (!open) setDeleteItem(null)
|
||||||
}}
|
}}
|
||||||
>
|
title="Delete class"
|
||||||
<AlertDialogContent>
|
description={`This will permanently delete ${deleteItem?.name || "this class"}.`}
|
||||||
<AlertDialogHeader>
|
onConfirm={handleDelete}
|
||||||
<AlertDialogTitle>Delete class</AlertDialogTitle>
|
isWorking={isWorking}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Plus } from "lucide-react"
|
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 defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
|
||||||
const [createClassId, setCreateClassId] = useState(defaultClassId)
|
const [createClassId, setCreateClassId] = useState(defaultClassId)
|
||||||
const [weekday, setWeekday] = useState<string>("1")
|
const [weekday, setWeekday] = useState<string>("1")
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open)
|
||||||
|
|
||||||
useEffect(() => {
|
if (open !== prevOpen) {
|
||||||
if (!open) return
|
setPrevOpen(open)
|
||||||
setCreateClassId(defaultClassId)
|
if (open) {
|
||||||
setWeekday("1")
|
setCreateClassId(defaultClassId)
|
||||||
}, [open, defaultClassId])
|
setWeekday("1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
const handleCreate = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
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 classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
|
||||||
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||||
if (!editItem) return
|
if (editItem !== prevEditItem) {
|
||||||
setEditClassId(editItem.classId)
|
setPrevEditItem(editItem)
|
||||||
setEditWeekday(String(editItem.weekday))
|
if (editItem) {
|
||||||
}, [editItem])
|
setEditClassId(editItem.classId)
|
||||||
|
setEditWeekday(String(editItem.weekday))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||||
if (!createOpen) return
|
if (createOpen !== prevCreateOpen) {
|
||||||
setCreateClassId(defaultClassId)
|
setPrevCreateOpen(createOpen)
|
||||||
}, [createOpen, defaultClassId])
|
if (createOpen) {
|
||||||
|
setCreateClassId(defaultClassId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
||||||
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
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 ?? ""))
|
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(open)
|
||||||
if (!open) return
|
if (open !== prevOpen) {
|
||||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
setPrevOpen(open)
|
||||||
}, [open, effectiveClassId, classes])
|
if (open) {
|
||||||
|
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleEnroll = async (formData: FormData) => {
|
const handleEnroll = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { toast } from "sonner"
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -16,16 +16,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import {
|
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import type { ClassStudent } from "../types"
|
import type { ClassStudent } from "../types"
|
||||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||||
|
|
||||||
@@ -79,11 +70,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
{s.className}
|
{s.className}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px]">
|
<span className="text-[10px]">
|
||||||
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
{formatDate(s.joinedAt, "en-GB")}
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "2-digit"
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,41 +148,29 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog
|
<ConfirmDeleteDialog
|
||||||
open={Boolean(removeTarget)}
|
open={Boolean(removeTarget)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (workingKey !== null) return
|
if (workingKey !== null) return
|
||||||
if (!open) setRemoveTarget(null)
|
if (!open) setRemoveTarget(null)
|
||||||
}}
|
}}
|
||||||
>
|
title="Remove student from class?"
|
||||||
<AlertDialogContent>
|
confirmText="Remove"
|
||||||
<AlertDialogHeader>
|
description={
|
||||||
<AlertDialogTitle>Remove student from class?</AlertDialogTitle>
|
removeTarget ? (
|
||||||
<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>.
|
||||||
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
|
||||||
</>
|
}
|
||||||
) : null}
|
onConfirm={() => {
|
||||||
</AlertDialogDescription>
|
if (!removeTarget) return
|
||||||
</AlertDialogHeader>
|
setRemoveTarget(null)
|
||||||
<AlertDialogFooter>
|
setStatus(removeTarget, "inactive")
|
||||||
<AlertDialogCancel disabled={workingKey !== null}>Cancel</AlertDialogCancel>
|
}}
|
||||||
<AlertDialogAction
|
isWorking={workingKey !== null}
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,28 @@ export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id:
|
|||||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
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[]> => {
|
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||||
if (!teacherId) return []
|
if (!teacherId) return []
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
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 { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateCoursePlanSchema,
|
CreateCoursePlanSchema,
|
||||||
@@ -23,12 +24,6 @@ import {
|
|||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem } from "./types"
|
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) => {
|
const revalidatePlanPaths = (id?: string) => {
|
||||||
revalidatePath("/admin/course-plans")
|
revalidatePath("/admin/course-plans")
|
||||||
revalidatePath("/teacher/course-plans")
|
revalidatePath("/teacher/course-plans")
|
||||||
@@ -72,7 +67,7 @@ export async function createCoursePlanAction(
|
|||||||
revalidatePlanPaths(id)
|
revalidatePlanPaths(id)
|
||||||
return { success: true, message: "Course plan created", data: id }
|
return { success: true, message: "Course plan created", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +110,7 @@ export async function updateCoursePlanAction(
|
|||||||
revalidatePlanPaths(id)
|
revalidatePlanPaths(id)
|
||||||
return { success: true, message: "Course plan updated", data: id }
|
return { success: true, message: "Course plan updated", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +127,7 @@ export async function deleteCoursePlanAction(
|
|||||||
revalidatePlanPaths()
|
revalidatePlanPaths()
|
||||||
return { success: true, message: "Course plan deleted" }
|
return { success: true, message: "Course plan deleted" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +139,7 @@ export async function getCoursePlansAction(
|
|||||||
const data = await getCoursePlans(params)
|
const data = await getCoursePlans(params)
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} 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" }
|
if (!data) return { success: false, message: "Course plan not found" }
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +185,7 @@ export async function createCoursePlanItemAction(
|
|||||||
revalidatePlanPaths(parsed.data.planId)
|
revalidatePlanPaths(parsed.data.planId)
|
||||||
return { success: true, message: "Week plan added", data: itemId }
|
return { success: true, message: "Week plan added", data: itemId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +225,7 @@ export async function updateCoursePlanItemAction(
|
|||||||
revalidatePlanPaths()
|
revalidatePlanPaths()
|
||||||
return { success: true, message: "Week plan updated", data: id }
|
return { success: true, message: "Week plan updated", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +238,7 @@ export async function deleteCoursePlanItemAction(
|
|||||||
revalidatePlanPaths()
|
revalidatePlanPaths()
|
||||||
return { success: true, message: "Week plan deleted" }
|
return { success: true, message: "Week plan deleted" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +258,6 @@ export async function toggleCoursePlanItemCompletedAction(
|
|||||||
message: completed ? "Marked as completed" : "Marked as incomplete",
|
message: completed ? "Marked as completed" : "Marked as incomplete",
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
subjects,
|
subjects,
|
||||||
users,
|
users,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
CoursePlanItem,
|
CoursePlanItem,
|
||||||
@@ -203,8 +204,8 @@ export async function createCoursePlan(
|
|||||||
totalHours: data.totalHours,
|
totalHours: data.totalHours,
|
||||||
completedHours: 0,
|
completedHours: 0,
|
||||||
weeklyHours: data.weeklyHours,
|
weeklyHours: data.weeklyHours,
|
||||||
startDate: data.startDate ? new Date(data.startDate) : null,
|
startDate: data.startDate ? safeParseDate(data.startDate, "开始日期") : null,
|
||||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null,
|
||||||
syllabus: data.syllabus,
|
syllabus: data.syllabus,
|
||||||
objectives: data.objectives,
|
objectives: data.objectives,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
@@ -227,9 +228,9 @@ export async function updateCoursePlan(
|
|||||||
if (data.completedHours !== undefined) update.completedHours = data.completedHours
|
if (data.completedHours !== undefined) update.completedHours = data.completedHours
|
||||||
if (data.weeklyHours !== undefined) update.weeklyHours = data.weeklyHours
|
if (data.weeklyHours !== undefined) update.weeklyHours = data.weeklyHours
|
||||||
if (data.startDate !== undefined)
|
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)
|
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.syllabus !== undefined) update.syllabus = data.syllabus
|
||||||
if (data.objectives !== undefined) update.objectives = data.objectives
|
if (data.objectives !== undefined) update.objectives = data.objectives
|
||||||
if (data.status !== undefined) update.status = data.status
|
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.notes !== undefined) update.notes = data.notes
|
||||||
if (data.isCompleted !== undefined) update.isCompleted = data.isCompleted
|
if (data.isCompleted !== undefined) update.isCompleted = data.isCompleted
|
||||||
if (data.completedAt !== undefined)
|
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
|
if (Object.keys(update).length === 0) return
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { z } from "zod"
|
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
|
export const CreateCoursePlanSchema = z
|
||||||
.object({
|
.object({
|
||||||
classId: z.string().trim().min(1),
|
classId: z.string().trim().min(1),
|
||||||
@@ -9,8 +15,18 @@ export const CreateCoursePlanSchema = z
|
|||||||
semester: z.enum(["1", "2"]).optional(),
|
semester: z.enum(["1", "2"]).optional(),
|
||||||
totalHours: z.coerce.number().int().min(0).optional(),
|
totalHours: z.coerce.number().int().min(0).optional(),
|
||||||
weeklyHours: z.coerce.number().int().min(0).optional(),
|
weeklyHours: z.coerce.number().int().min(0).optional(),
|
||||||
startDate: z.string().trim().optional().nullable(),
|
startDate: z
|
||||||
endDate: z.string().trim().optional().nullable(),
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine(isValidDateString, "开始日期格式无效"),
|
||||||
|
endDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine(isValidDateString, "结束日期格式无效"),
|
||||||
syllabus: z.string().trim().optional().nullable(),
|
syllabus: z.string().trim().optional().nullable(),
|
||||||
objectives: z.string().trim().optional().nullable(),
|
objectives: z.string().trim().optional().nullable(),
|
||||||
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
||||||
@@ -42,8 +58,18 @@ export const UpdateCoursePlanSchema = z
|
|||||||
totalHours: z.coerce.number().int().min(0).optional(),
|
totalHours: z.coerce.number().int().min(0).optional(),
|
||||||
completedHours: z.coerce.number().int().min(0).optional(),
|
completedHours: z.coerce.number().int().min(0).optional(),
|
||||||
weeklyHours: z.coerce.number().int().min(0).optional(),
|
weeklyHours: z.coerce.number().int().min(0).optional(),
|
||||||
startDate: z.string().trim().optional().nullable(),
|
startDate: z
|
||||||
endDate: z.string().trim().optional().nullable(),
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine(isValidDateString, "开始日期格式无效"),
|
||||||
|
endDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine(isValidDateString, "结束日期格式无效"),
|
||||||
syllabus: z.string().trim().optional().nullable(),
|
syllabus: z.string().trim().optional().nullable(),
|
||||||
objectives: z.string().trim().optional().nullable(),
|
objectives: z.string().trim().optional().nullable(),
|
||||||
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
||||||
@@ -116,7 +142,12 @@ export const UpdateCoursePlanItemSchema = z
|
|||||||
textbookChapter: z.string().trim().optional().nullable(),
|
textbookChapter: z.string().trim().optional().nullable(),
|
||||||
notes: z.string().trim().optional().nullable(),
|
notes: z.string().trim().optional().nullable(),
|
||||||
isCompleted: z.boolean().optional(),
|
isCompleted: z.boolean().optional(),
|
||||||
completedAt: z.string().trim().optional().nullable(),
|
completedAt: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine(isValidDateString, "完成日期格式无效"),
|
||||||
})
|
})
|
||||||
.transform((v) => ({
|
.transform((v) => ({
|
||||||
...v,
|
...v,
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export const CourseSelectionStatusEnum = z.enum([
|
|||||||
"rejected",
|
"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 =>
|
const emptyToNull = (v: string | undefined | null): string | null =>
|
||||||
v && v.length > 0 ? v : 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(),
|
capacity: z.coerce.number().int().min(1).max(500).optional(),
|
||||||
classroom: z.string().trim().optional().nullable(),
|
classroom: z.string().trim().optional().nullable(),
|
||||||
schedule: z.string().trim().optional().nullable(),
|
schedule: z.string().trim().optional().nullable(),
|
||||||
startDate: z.string().trim().optional().nullable(),
|
startDate: z
|
||||||
endDate: z.string().trim().optional().nullable(),
|
.string()
|
||||||
selectionStartAt: z.string().trim().optional().nullable(),
|
.trim()
|
||||||
selectionEndAt: z.string().trim().optional().nullable(),
|
.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(),
|
selectionMode: ElectiveSelectionModeEnum.optional(),
|
||||||
credit: z.string().trim().optional().nullable(),
|
credit: z.string().trim().optional().nullable(),
|
||||||
})
|
})
|
||||||
@@ -69,10 +95,30 @@ export const UpdateElectiveCourseSchema = z
|
|||||||
capacity: z.coerce.number().int().min(1).max(500).optional(),
|
capacity: z.coerce.number().int().min(1).max(500).optional(),
|
||||||
classroom: z.string().trim().optional().nullable(),
|
classroom: z.string().trim().optional().nullable(),
|
||||||
schedule: z.string().trim().optional().nullable(),
|
schedule: z.string().trim().optional().nullable(),
|
||||||
startDate: z.string().trim().optional().nullable(),
|
startDate: z
|
||||||
endDate: z.string().trim().optional().nullable(),
|
.string()
|
||||||
selectionStartAt: z.string().trim().optional().nullable(),
|
.trim()
|
||||||
selectionEndAt: z.string().trim().optional().nullable(),
|
.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(),
|
status: ElectiveCourseStatusEnum.optional(),
|
||||||
selectionMode: ElectiveSelectionModeEnum.optional(),
|
selectionMode: ElectiveSelectionModeEnum.optional(),
|
||||||
credit: z.string().trim().optional().nullable(),
|
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 { Permissions } from "@/shared/types/permissions"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
import {
|
||||||
|
handleActionError,
|
||||||
|
safeJsonParse,
|
||||||
|
} from "@/shared/lib/action-utils"
|
||||||
|
import { trackExamEvent } from "@/shared/lib/track-event"
|
||||||
import {
|
import {
|
||||||
buildExamDescription,
|
buildExamDescription,
|
||||||
deleteExamById,
|
deleteExamById,
|
||||||
@@ -73,12 +78,15 @@ const parseExamModeConfig = (formData: FormData): ExamModeConfig => {
|
|||||||
const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration))
|
const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration))
|
||||||
? Number(rawDuration)
|
? Number(rawDuration)
|
||||||
: null
|
: null
|
||||||
|
const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0"
|
||||||
|
const parsedGrace = Number(rawGrace)
|
||||||
|
const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0
|
||||||
return {
|
return {
|
||||||
examMode,
|
examMode,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false),
|
shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false),
|
||||||
allowLateStart: getBoolValue(formData, "allowLateStart", false),
|
allowLateStart: getBoolValue(formData, "allowLateStart", false),
|
||||||
lateStartGraceMinutes: Number(getStringValue(formData, "lateStartGraceMinutes") ?? "0") || 0,
|
lateStartGraceMinutes,
|
||||||
antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false),
|
antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +323,7 @@ export async function createExamAction(
|
|||||||
totalScore: getStringValue(formData, "totalScore"),
|
totalScore: getStringValue(formData, "totalScore"),
|
||||||
durationMin: getStringValue(formData, "durationMin"),
|
durationMin: getStringValue(formData, "durationMin"),
|
||||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -345,7 +353,7 @@ export async function createExamAction(
|
|||||||
examModeConfig: parseExamModeConfig(formData),
|
examModeConfig: parseExamModeConfig(formData),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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")
|
return failState<string>("Database error: Failed to create exam")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +364,7 @@ export async function createExamAction(
|
|||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<string>(error.message)
|
return failState<string>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +411,7 @@ export async function createAiExamAction(
|
|||||||
totalScore: getStringValue(formData, "totalScore"),
|
totalScore: getStringValue(formData, "totalScore"),
|
||||||
durationMin: getStringValue(formData, "durationMin"),
|
durationMin: getStringValue(formData, "durationMin"),
|
||||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [],
|
||||||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||||
? aiQuestionCountRaw
|
? aiQuestionCountRaw
|
||||||
@@ -465,18 +473,28 @@ export async function createAiExamAction(
|
|||||||
examModeConfig: parseExamModeConfig(formData),
|
examModeConfig: parseExamModeConfig(formData),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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")
|
return failState<string>("Database error: Failed to create exam")
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/teacher/exams/all")
|
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.")
|
return successState(context.examId, "Exam created successfully.")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<string>(error.message)
|
return failState<string>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +547,7 @@ export async function previewAiExamAction(
|
|||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<AiPreviewData>(error.message)
|
return failState<AiPreviewData>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,14 +583,15 @@ export async function regenerateAiQuestionAction(
|
|||||||
score: result.data.score ?? originalScore,
|
score: result.data.score ?? originalScore,
|
||||||
content: result.data.content,
|
content: result.data.content,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<AiRewriteQuestionData>(error.message)
|
return failState<AiRewriteQuestionData>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,13 +618,13 @@ export async function updateExamAction(
|
|||||||
|
|
||||||
const rawQuestions = formData.get("questionsJson")
|
const rawQuestions = formData.get("questionsJson")
|
||||||
const rawStructure = formData.get("structureJson")
|
const rawStructure = formData.get("structureJson")
|
||||||
const hasQuestions = typeof rawQuestions === "string"
|
const rawQuestionsStr = typeof rawQuestions === "string" ? rawQuestions : null
|
||||||
const hasStructure = typeof rawStructure === "string"
|
const rawStructureStr = typeof rawStructure === "string" ? rawStructure : null
|
||||||
|
|
||||||
const parsed = ExamUpdateSchema.safeParse({
|
const parsed = ExamUpdateSchema.safeParse({
|
||||||
examId: formData.get("examId"),
|
examId: formData.get("examId"),
|
||||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
questions: rawQuestionsStr ? safeJsonParse(rawQuestionsStr, "题目数据格式无效") : undefined,
|
||||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
structure: rawStructureStr ? safeJsonParse(rawStructureStr, "试卷结构数据格式无效") : undefined,
|
||||||
status: formData.get("status") ?? undefined,
|
status: formData.get("status") ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -632,18 +651,26 @@ export async function updateExamAction(
|
|||||||
structure,
|
structure,
|
||||||
status,
|
status,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||||
return failState<string>("Database error: Failed to update exam")
|
return failState<string>("Database error: Failed to update exam")
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/teacher/exams/all")
|
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")
|
return successState(examId, "Exam updated")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<string>(error.message)
|
return failState<string>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,18 +708,25 @@ export async function deleteExamAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteExamById(examId)
|
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")
|
return failState<string>("Database error: Failed to delete exam")
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/teacher/exams/all")
|
revalidatePath("/teacher/exams/all")
|
||||||
|
|
||||||
|
// V3-4: 埋点监控
|
||||||
|
await trackExamEvent("exam.deleted", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: examId,
|
||||||
|
})
|
||||||
|
|
||||||
return successState(examId, "Exam deleted")
|
return successState(examId, "Exam deleted")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<string>(error.message)
|
return failState<string>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,18 +761,26 @@ export async function duplicateExamAction(
|
|||||||
return failState<string>("Exam not found")
|
return failState<string>("Exam not found")
|
||||||
}
|
}
|
||||||
newExamId = duplicatedId
|
newExamId = duplicatedId
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||||
return failState<string>("Database error: Failed to duplicate exam")
|
return failState<string>("Database error: Failed to duplicate exam")
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/teacher/exams/all")
|
revalidatePath("/teacher/exams/all")
|
||||||
|
|
||||||
|
// V3-4: 埋点监控
|
||||||
|
await trackExamEvent("exam.duplicated", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: newExamId,
|
||||||
|
properties: { sourceExamId: examId },
|
||||||
|
})
|
||||||
|
|
||||||
return successState(newExamId, "Exam duplicated")
|
return successState(newExamId, "Exam duplicated")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<string>(error.message)
|
return failState<string>(error.message)
|
||||||
}
|
}
|
||||||
throw error
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,14 +801,14 @@ export async function getExamPreviewAction(
|
|||||||
questions: exam.questions,
|
questions: exam.questions,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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")
|
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message)
|
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()
|
const allSubjects = await getExamSubjects()
|
||||||
return successState(allSubjects)
|
return successState(allSubjects)
|
||||||
} catch (error) {
|
} 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")
|
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<{ id: string; name: string }[]>(error.message)
|
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()
|
const allGrades = await getExamGrades()
|
||||||
return successState(allGrades)
|
return successState(allGrades)
|
||||||
} catch (error) {
|
} 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")
|
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return failState<{ id: string; name: string }[]>(error.message)
|
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) {
|
while (cursor < items.length) {
|
||||||
const index = cursor
|
const index = cursor
|
||||||
cursor += 1
|
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())
|
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
import type { ExamNode } from "./selected-question-list"
|
import type { ExamNode } from "./selected-question-list"
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
@@ -21,23 +22,41 @@ type ExamPaperPreviewProps = {
|
|||||||
nodes: ExamNode[]
|
nodes: ExamNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
|
const parseContent = (raw: unknown): QuestionContent => {
|
||||||
// Helper to flatten questions for continuous numbering
|
if (raw && typeof raw === "object") return raw as QuestionContent
|
||||||
let questionCounter = 0
|
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 => {
|
// Precompute question numbers as a Map to avoid mutating a counter during render
|
||||||
if (raw && typeof raw === "object") return raw as QuestionContent
|
const buildQuestionNumberMap = (nodes: ExamNode[]): Map<string, number> => {
|
||||||
if (typeof raw === "string") {
|
const map = new Map<string, number>()
|
||||||
try {
|
let counter = 0
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const walk = (list: ExamNode[]) => {
|
||||||
if (parsed && typeof parsed === "object") return parsed as QuestionContent
|
for (const node of list) {
|
||||||
return { text: raw }
|
if (node.type === "question" && node.question) {
|
||||||
} catch {
|
counter += 1
|
||||||
return { text: raw }
|
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) => {
|
const renderNode = (node: ExamNode, depth: number = 0) => {
|
||||||
if (node.type === 'group') {
|
if (node.type === 'group') {
|
||||||
@@ -57,20 +76,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'question' && node.question) {
|
if (node.type === 'question' && node.question) {
|
||||||
questionCounter++
|
const questionNumber = numberMap.get(node.id) ?? 0
|
||||||
const q = node.question
|
const q = node.question
|
||||||
const content = parseContent(q.content)
|
const content = parseContent(q.content)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id} className="mb-6 break-inside-avoid">
|
<div key={node.id} className="mb-6 break-inside-avoid">
|
||||||
<div className="flex gap-2">
|
<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="flex-1 space-y-2">
|
||||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||||
{content.text ?? ""}
|
{content.text ?? ""}
|
||||||
<span className="text-muted-foreground text-sm ml-2">({node.score}分)</span>
|
<span className="text-muted-foreground text-sm ml-2">({node.score}分)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options for Choice Questions */}
|
{/* Options for Choice Questions */}
|
||||||
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
|
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function SelectedQuestionList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
<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>
|
<div className="text-xs text-muted-foreground italic py-2">Drag questions here or add from bank</div>
|
||||||
) : (
|
) : (
|
||||||
node.children?.map((child, cIdx) => (
|
node.children?.map((child, cIdx) => (
|
||||||
@@ -191,13 +191,13 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
|||||||
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
||||||
Score
|
Score
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`score-${item.id}`}
|
id={`score-${item.id}`}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
className="h-7 w-16 text-right"
|
className="h-7 w-16 text-right"
|
||||||
value={item.score}
|
value={item.score}
|
||||||
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
|
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useMemo, useState } from "react"
|
import React, { useCallback, useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
pointerWithin,
|
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 ---
|
// --- Components ---
|
||||||
|
|
||||||
function SortableItem({
|
function SortableItem({
|
||||||
@@ -135,13 +159,13 @@ function SortableItem({
|
|||||||
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
||||||
Score
|
Score
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`score-${item.id}`}
|
id={`score-${item.id}`}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
className="h-7 w-16 text-right"
|
className="h-7 w-16 text-right"
|
||||||
value={item.score}
|
value={item.score}
|
||||||
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
|
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,6 +203,7 @@ function SortableGroup({
|
|||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const childrenKey = JSON.stringify(item.children || [])
|
||||||
const totalScore = useMemo(() => {
|
const totalScore = useMemo(() => {
|
||||||
const calc = (nodes: ExamNode[]): number => {
|
const calc = (nodes: ExamNode[]): number => {
|
||||||
return nodes.reduce((acc, node) => {
|
return nodes.reduce((acc, node) => {
|
||||||
@@ -188,7 +213,8 @@ function SortableGroup({
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
return calc(item.children || [])
|
return calc(item.children || [])
|
||||||
}, [item])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [childrenKey])
|
||||||
|
|
||||||
return (
|
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")}>
|
<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")}>
|
||||||
@@ -227,13 +253,14 @@ function SortableGroup({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StructureRenderer({ nodes, ...props }: {
|
function StructureRenderer({ nodes, ...props }: {
|
||||||
nodes: ExamNode[]
|
nodes: ExamNode[]
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
onScoreChange: (id: string, score: number) => void
|
onScoreChange: (id: string, score: number) => void
|
||||||
onGroupTitleChange: (id: string, title: string) => void
|
onGroupTitleChange: (id: string, title: string) => void
|
||||||
}) {
|
}) {
|
||||||
// Deduplicate nodes to prevent React key errors
|
// Deduplicate nodes to prevent React key errors
|
||||||
|
const nodesKey = JSON.stringify(nodes.map(n => n.id))
|
||||||
const uniqueNodes = useMemo(() => {
|
const uniqueNodes = useMemo(() => {
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
return nodes.filter(n => {
|
return nodes.filter(n => {
|
||||||
@@ -241,7 +268,8 @@ function StructureRenderer({ nodes, ...props }: {
|
|||||||
seen.add(n.id)
|
seen.add(n.id)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [nodes])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nodesKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||||
@@ -294,7 +322,7 @@ const dropAnimation: DropAnimation = {
|
|||||||
|
|
||||||
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) {
|
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null)
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -303,30 +331,33 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Recursively find item
|
// Recursively find item
|
||||||
const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
const findItem = useCallback((id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
||||||
for (const node of nodes) {
|
const walk = (list: ExamNode[]): ExamNode | null => {
|
||||||
if (node.id === id) return node
|
for (const node of list) {
|
||||||
if (node.children) {
|
if (node.id === id) return node
|
||||||
const found = findItem(id, node.children)
|
if (node.children) {
|
||||||
if (found) return found
|
const found = walk(node.children)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return null
|
return walk(nodes)
|
||||||
}
|
}, [items])
|
||||||
|
|
||||||
const activeItem = activeId ? findItem(activeId) : null
|
const activeItem = activeId ? findItem(activeId) : null
|
||||||
|
|
||||||
// DND Handlers
|
// DND Handlers
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string)
|
setActiveId(event.active.id as string)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
// Custom collision detection for nested sortables
|
// Custom collision detection for nested sortables
|
||||||
const customCollisionDetection: CollisionDetection = (args) => {
|
const customCollisionDetection: CollisionDetection = useCallback((args) => {
|
||||||
// 1. First check pointer within for precise container detection
|
// 1. First check pointer within for precise container detection
|
||||||
const pointerCollisions = pointerWithin(args)
|
const pointerCollisions = pointerWithin(args)
|
||||||
|
|
||||||
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
|
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
|
||||||
if (pointerCollisions.length > 0) {
|
if (pointerCollisions.length > 0) {
|
||||||
return pointerCollisions
|
return pointerCollisions
|
||||||
@@ -334,7 +365,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
|||||||
|
|
||||||
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
|
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
|
||||||
return rectIntersection(args)
|
return rectIntersection(args)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
function handleDragOver(event: DragOverEvent) {
|
function handleDragOver(event: DragOverEvent) {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
@@ -557,15 +588,15 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
|||||||
|
|
||||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||||
const moved = arrayMove(list, oldIndex, newIndex)
|
const moved = arrayMove(list, oldIndex, newIndex)
|
||||||
|
|
||||||
// Update the list reference in parent
|
// Update the list reference in parent
|
||||||
if (activeContainerId === 'root') {
|
if (activeContainerId === 'root') {
|
||||||
onChange(moved)
|
onChange(moved)
|
||||||
} else {
|
} else if (activeContainerId) {
|
||||||
// list is already a reference to children array if we did it right?
|
// 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.
|
// getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array.
|
||||||
// So we need to re-assign.
|
// So we need to re-assign.
|
||||||
const group = findItem(activeContainerId!, newItems)
|
const group = findItem(activeContainerId, newItems)
|
||||||
if (group) group.children = moved
|
if (group) group.children = moved
|
||||||
onChange(newItems)
|
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">
|
<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" />
|
<GripVertical className="h-4 w-4" />
|
||||||
<p className="text-sm line-clamp-1">
|
<p className="text-sm line-clamp-1">
|
||||||
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
|
{extractQuestionText(activeItem.question?.content) || "Question"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
import { useMemo, type ReactNode } from "react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
@@ -37,6 +37,24 @@ type ExamPreviewDialogProps = {
|
|||||||
previewTitleValue?: string
|
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({
|
export function ExamPreviewDialog({
|
||||||
previewOpen,
|
previewOpen,
|
||||||
setPreviewOpen,
|
setPreviewOpen,
|
||||||
@@ -59,8 +77,10 @@ export function ExamPreviewDialog({
|
|||||||
handleConfirmCreate,
|
handleConfirmCreate,
|
||||||
previewTitleValue,
|
previewTitleValue,
|
||||||
}: ExamPreviewDialogProps) {
|
}: ExamPreviewDialogProps) {
|
||||||
|
// Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment.
|
||||||
|
const numberMap = useMemo(() => buildQuestionNumberMap(previewNodes), [previewNodes])
|
||||||
|
|
||||||
const renderSelectablePreview = (nodes: ExamNode[]) => {
|
const renderSelectablePreview = (nodes: ExamNode[]) => {
|
||||||
let questionCounter = 0
|
|
||||||
const renderNode = (node: ExamNode, depth: number = 0): ReactNode => {
|
const renderNode = (node: ExamNode, depth: number = 0): ReactNode => {
|
||||||
if (node.type === "group") {
|
if (node.type === "group") {
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +95,7 @@ export function ExamPreviewDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (node.type === "question" && node.question && node.questionId) {
|
if (node.type === "question" && node.question && node.questionId) {
|
||||||
questionCounter += 1
|
const questionNumber = numberMap.get(node.id) ?? 0
|
||||||
const content = parseEditableContent(node.question.content)
|
const content = parseEditableContent(node.question.content)
|
||||||
const active = node.questionId === selectedQuestionId
|
const active = node.questionId === selectedQuestionId
|
||||||
return (
|
return (
|
||||||
@@ -89,7 +109,7 @@ export function ExamPreviewDialog({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<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="flex-1 space-y-2">
|
||||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||||
{content.text || "未命名题目"}
|
{content.text || "未命名题目"}
|
||||||
|
|||||||
@@ -13,11 +13,23 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import type { ExamNode } from "./assembly/selected-question-list"
|
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 type { EditableQuestionContent } from "./exam-form-types"
|
||||||
import { QuestionOptionsEditor } from "./question-options-editor"
|
import { QuestionOptionsEditor } from "./question-options-editor"
|
||||||
import { QuestionSubQuestionsEditor } from "./question-sub-questions-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 = {
|
type ExamPreviewQuestionEditorProps = {
|
||||||
selectedQuestion: ExamNode | null
|
selectedQuestion: ExamNode | null
|
||||||
selectedContent: EditableQuestionContent | null
|
selectedContent: EditableQuestionContent | null
|
||||||
@@ -67,7 +79,9 @@ export function ExamPreviewQuestionEditor({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||||
if (!node.question) return 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 { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||||
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||||
import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/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 { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||||
@@ -64,7 +65,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
|||||||
const conditions = []
|
const conditions = []
|
||||||
|
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
const search = `%${params.q}%`
|
const search = `%${escapeLikePattern(params.q)}%`
|
||||||
conditions.push(or(like(exams.title, search), like(exams.description, search)))
|
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()))
|
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||||
if (gradeIds.length > 0) {
|
if (gradeIds.length > 0) {
|
||||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
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) {
|
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||||
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
|
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
|
// "all" type: no filtering
|
||||||
// "class_members": student sees published exams for their grade (would need student's gradeId)
|
// "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") {
|
if (params.difficulty && params.difficulty !== "all") {
|
||||||
const d = parseInt(params.difficulty)
|
const d = parseInt(params.difficulty, 10)
|
||||||
result = result.filter((e) => e.difficulty === d)
|
if (!Number.isNaN(d)) {
|
||||||
|
result = result.filter((e) => e.difficulty === d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -155,13 +167,20 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
|||||||
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
|
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) {
|
if (scope.type === "grade_managed") {
|
||||||
return null
|
// 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 classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
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
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +201,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
|||||||
createdAt: exam.createdAt.toISOString(),
|
createdAt: exam.createdAt.toISOString(),
|
||||||
updatedAt: exam.updatedAt?.toISOString(),
|
updatedAt: exam.updatedAt?.toISOString(),
|
||||||
tags: getStringArray(meta, "tags") || [],
|
tags: getStringArray(meta, "tags") || [],
|
||||||
structure: exam.structure as unknown,
|
structure: exam.structure,
|
||||||
questions: exam.questions.map((eqRel) => ({
|
questions: exam.questions.map((eqRel) => ({
|
||||||
id: eqRel.questionId,
|
id: eqRel.questionId,
|
||||||
score: eqRel.score ?? 0,
|
score: eqRel.score ?? 0,
|
||||||
@@ -379,14 +398,26 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
|||||||
if (scope.type === "owned") {
|
if (scope.type === "owned") {
|
||||||
conditions.push(eq(exams.creatorId, scope.userId))
|
conditions.push(eq(exams.creatorId, scope.userId))
|
||||||
}
|
}
|
||||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
if (scope.type === "grade_managed") {
|
||||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
// P0 fix: empty gradeIds must NOT bypass filtering
|
||||||
|
if (scope.gradeIds.length === 0) {
|
||||||
|
conditions.push(eq(exams.id, "__none__"))
|
||||||
|
} else {
|
||||||
|
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
if (scope.type === "class_taught") {
|
||||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
// P0 fix: empty classIds must NOT bypass filtering
|
||||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
if (scope.classIds.length === 0) {
|
||||||
if (gradeIds.length > 0) {
|
conditions.push(eq(exams.id, "__none__"))
|
||||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
} 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,
|
buildPreviewRequestData,
|
||||||
} from "../components/exam-preview-utils"
|
} 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>) {
|
export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
@@ -48,7 +59,7 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
|
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const raw = window.localStorage.getItem(previewTaskStorageKey)
|
const raw = window.localStorage.getItem(previewTaskStorageKey)
|
||||||
if (!raw) return
|
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
|
if (!Array.isArray(parsed)) return
|
||||||
const restoredTasks = parsed
|
const restoredTasks = parsed
|
||||||
.filter((task) => task && typeof task.id === "string")
|
.filter(isPreviewBackgroundTask)
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
if (task.status === "queued" || task.status === "running") {
|
if (task.status === "queued" || task.status === "running") {
|
||||||
return {
|
return {
|
||||||
@@ -75,7 +92,7 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
|||||||
form.setValue("mode", "ai")
|
form.setValue("mode", "ai")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||||
setPreviewTasks([])
|
setPreviewTasks([])
|
||||||
}
|
}
|
||||||
}, [form])
|
}, [form])
|
||||||
@@ -150,7 +167,8 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
|||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to generate preview")
|
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")
|
toast.error("Failed to generate preview")
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false)
|
setPreviewLoading(false)
|
||||||
@@ -201,7 +219,8 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
|||||||
? { ...task, status: "failed", message: result.message || "Failed to generate preview" }
|
? { ...task, status: "failed", message: result.message || "Failed to generate preview" }
|
||||||
: task))
|
: task))
|
||||||
toast.error(`后台生成失败:${taskTitle}`)
|
toast.error(`后台生成失败:${taskTitle}`)
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||||
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
||||||
? { ...task, status: "failed", message: "Failed to generate preview" }
|
? { ...task, status: "failed", message: "Failed to generate preview" }
|
||||||
: task))
|
: task))
|
||||||
@@ -276,7 +295,8 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
|||||||
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
|
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
|
||||||
setRewriteInstruction("")
|
setRewriteInstruction("")
|
||||||
toast.success("题目已按指令重写")
|
toast.success("题目已按指令重写")
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||||
toast.error("AI 重写失败")
|
toast.error("AI 重写失败")
|
||||||
} finally {
|
} finally {
|
||||||
setRewritingQuestion(false)
|
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 列表批量查询文件(用于批量删除前获取磁盘路径)
|
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -72,13 +72,18 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
|
|||||||
formData.set("publish", "true")
|
formData.set("publish", "true")
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
const result = await createHomeworkAssignmentAction(null, formData)
|
try {
|
||||||
setIsSubmitting(false)
|
const result = await createHomeworkAssignmentAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
router.push("/teacher/homework/assignments")
|
router.push("/teacher/homework/assignments")
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || t("homework.form.createFailed"))
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{wrongAnswers.map((wa, i) => (
|
{wrongAnswers.map((wa) => (
|
||||||
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
<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">
|
<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 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>
|
<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>
|
</Label>
|
||||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||||
{initialData.questions.map((q, i) => {
|
{initialData.questions.map((q, i) => {
|
||||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
const answer = answersByQuestionId[q.questionId]?.answer
|
||||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
const hasAnswer = answer !== undefined &&
|
||||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer)
|
answer !== "" &&
|
||||||
? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0
|
(Array.isArray(answer) ? answer.length > 0 : true)
|
||||||
: true)
|
|
||||||
|
|
||||||
const score = q.score ?? 0
|
const score = q.score ?? 0
|
||||||
const max = q.maxScore
|
const max = q.maxScore
|
||||||
|
|||||||
@@ -70,27 +70,40 @@ export function useDebouncedAutoSave({
|
|||||||
savingRef.current = true
|
savingRef.current = true
|
||||||
setStatus("saving")
|
setStatus("saving")
|
||||||
|
|
||||||
let allOk = true
|
// 并行保存所有待保存答案,单个失败不影响其他答案
|
||||||
for (const [questionId, answer] of pending) {
|
const results = await Promise.allSettled(
|
||||||
const fd = new FormData()
|
pending.map(([questionId, answer]) => {
|
||||||
fd.set("submissionId", submissionId)
|
const fd = new FormData()
|
||||||
fd.set("questionId", questionId)
|
fd.set("submissionId", submissionId)
|
||||||
fd.set("answerJson", JSON.stringify({ answer }))
|
fd.set("questionId", questionId)
|
||||||
const res = await saveHomeworkAnswerAction(null, fd)
|
fd.set("answerJson", JSON.stringify({ answer }))
|
||||||
if (!res.success) {
|
return saveHomeworkAnswerAction(null, fd)
|
||||||
allOk = false
|
})
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
savingRef.current = false
|
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()
|
pendingRef.current.clear()
|
||||||
setStatus("saved")
|
setStatus("saved")
|
||||||
setLastSavedAt(Date.now())
|
setLastSavedAt(Date.now())
|
||||||
} else {
|
} else {
|
||||||
setStatus("error")
|
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])
|
}, [submissionId])
|
||||||
|
|
||||||
@@ -135,7 +148,12 @@ export function useDebouncedAutoSave({
|
|||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(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()
|
void savePending()
|
||||||
}
|
}
|
||||||
}, [savePending])
|
}, [savePending])
|
||||||
|
|||||||
@@ -161,6 +161,49 @@ export const computeIsCorrect = (input: {
|
|||||||
return null
|
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)的不覆盖
|
* - 已有分数(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[] => {
|
export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
|
||||||
return incoming.map((a) => {
|
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 })) {
|
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
|
||||||
return a
|
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({
|
const isCorrect = computeIsCorrect({
|
||||||
questionType: a.questionType,
|
questionType: a.questionType,
|
||||||
questionContent: a.questionContent,
|
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"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const CreateHomeworkAssignmentSchema = z.object({
|
const dateStringSchema = z
|
||||||
sourceExamId: z.string().optional(),
|
.string()
|
||||||
classId: z.string().min(1),
|
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format")
|
||||||
title: z.string().min(1, "Title is required for quick assignments"),
|
|
||||||
description: z.string().optional(),
|
export const CreateHomeworkAssignmentSchema = z
|
||||||
availableAt: z.string().optional(),
|
.object({
|
||||||
dueAt: z.string().optional(),
|
sourceExamId: z.string().optional(),
|
||||||
allowLate: z.coerce.boolean().optional(),
|
classId: z.string().min(1),
|
||||||
lateDueAt: z.string().optional(),
|
title: z.string().min(1, "Title is required for quick assignments"),
|
||||||
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
|
description: z.string().optional(),
|
||||||
targetStudentIds: z.array(z.string().min(1)).optional(),
|
availableAt: dateStringSchema.optional(),
|
||||||
publish: z.coerce.boolean().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>
|
export type CreateHomeworkAssignmentInput = z.infer<typeof CreateHomeworkAssignmentSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -115,36 +115,34 @@ export const getHomeworkAssignmentAnalytics = cache(
|
|||||||
|
|
||||||
if (!assignment) return null
|
if (!assignment) return null
|
||||||
|
|
||||||
const [targetsRow] = await db
|
const [targetsRows, submissionsRows, submittedRows, gradedRows, assignmentQuestions] = await Promise.all([
|
||||||
.select({ c: count() })
|
db
|
||||||
.from(homeworkAssignmentTargets)
|
.select({ c: count() })
|
||||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId)),
|
||||||
const [submissionsRow] = await db
|
db
|
||||||
.select({ c: count() })
|
.select({ c: count() })
|
||||||
.from(homeworkSubmissions)
|
.from(homeworkSubmissions)
|
||||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
.where(eq(homeworkSubmissions.assignmentId, assignmentId)),
|
||||||
|
db
|
||||||
const [submittedRow] = await db
|
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
.from(homeworkSubmissions)
|
||||||
.from(homeworkSubmissions)
|
.where(
|
||||||
.where(
|
and(
|
||||||
and(
|
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
)
|
||||||
)
|
),
|
||||||
)
|
db
|
||||||
|
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
const [gradedRow] = await db
|
.from(homeworkSubmissions)
|
||||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded"))),
|
||||||
.from(homeworkSubmissions)
|
db.query.homeworkAssignmentQuestions.findMany({
|
||||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
|
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||||
|
with: { question: true },
|
||||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
orderBy: (q, { asc }) => [asc(q.order)],
|
||||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
}),
|
||||||
with: { question: true },
|
])
|
||||||
orderBy: (q, { asc }) => [asc(q.order)],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
||||||
|
|
||||||
@@ -235,10 +233,10 @@ export const getHomeworkAssignmentAnalytics = cache(
|
|||||||
allowLate: assignment.allowLate,
|
allowLate: assignment.allowLate,
|
||||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||||
maxAttempts: assignment.maxAttempts,
|
maxAttempts: assignment.maxAttempts,
|
||||||
targetCount: targetsRow?.c ?? 0,
|
targetCount: targetsRows[0]?.c ?? 0,
|
||||||
submissionCount: submissionsRow?.c ?? 0,
|
submissionCount: submissionsRows[0]?.c ?? 0,
|
||||||
submittedCount: submittedRow?.c ?? 0,
|
submittedCount: submittedRows[0]?.c ?? 0,
|
||||||
gradedCount: gradedRow?.c ?? 0,
|
gradedCount: gradedRows[0]?.c ?? 0,
|
||||||
createdAt: assignment.createdAt.toISOString(),
|
createdAt: assignment.createdAt.toISOString(),
|
||||||
updatedAt: assignment.updatedAt.toISOString(),
|
updatedAt: assignment.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/shared/components/ui/collapsible"
|
} from "@/shared/components/ui/collapsible"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -35,11 +28,13 @@ interface AppSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ mode }: AppSidebarProps) {
|
export function AppSidebar({ mode }: AppSidebarProps) {
|
||||||
const { expanded, toggleSidebar, isMobile, currentRole, setCurrentRole } = useSidebar()
|
const { expanded, toggleSidebar, isMobile } = useSidebar()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { permissions, roles, hasRole } = usePermission()
|
const { permissions, hasRole } = usePermission()
|
||||||
|
|
||||||
// 自动检测当前角色(优先级 admin > student > parent > teacher)
|
// 自动检测当前角色(优先级 admin > student > parent > teacher)
|
||||||
|
// 注意:grade_head / teaching_head 统一归入 teacher,因为 teacher 导航已通过
|
||||||
|
// 权限点(GRADE_MANAGE 等)动态显示班主任专属功能,无需切换角色。
|
||||||
function detectAutoRole(): Role {
|
function detectAutoRole(): Role {
|
||||||
if (hasRole("admin")) return "admin"
|
if (hasRole("admin")) return "admin"
|
||||||
if (hasRole("student")) return "student"
|
if (hasRole("student")) return "student"
|
||||||
@@ -47,14 +42,7 @@ export function AppSidebar({ mode }: AppSidebarProps) {
|
|||||||
return "teacher"
|
return "teacher"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户在 NAV_CONFIG 中实际可用的角色(过滤掉未配置的角色)
|
const effectiveRole: Role = detectAutoRole()
|
||||||
const availableRoles = roles.filter((r) => NAV_CONFIG[r] !== undefined)
|
|
||||||
|
|
||||||
// 如果 context 中有 currentRole 且用户拥有该角色,使用 currentRole;否则自动检测
|
|
||||||
const effectiveRole: Role =
|
|
||||||
currentRole !== null && availableRoles.includes(currentRole)
|
|
||||||
? currentRole
|
|
||||||
: detectAutoRole()
|
|
||||||
|
|
||||||
const allNavItems = NAV_CONFIG[effectiveRole] ?? NAV_CONFIG.teacher ?? []
|
const allNavItems = NAV_CONFIG[effectiveRole] ?? NAV_CONFIG.teacher ?? []
|
||||||
|
|
||||||
@@ -71,7 +59,7 @@ export function AppSidebar({ mode }: AppSidebarProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Ensure consistent state for hydration
|
// Ensure consistent state for hydration
|
||||||
if (!expanded && mode === 'mobile') return null
|
if (!expanded && mode === 'mobile') return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-4">
|
<div className="flex h-full flex-col gap-4">
|
||||||
@@ -179,26 +167,12 @@ export function AppSidebar({ mode }: AppSidebarProps) {
|
|||||||
|
|
||||||
{/* Sidebar Footer */}
|
{/* Sidebar Footer */}
|
||||||
<div className="p-4">
|
<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 && (
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/shared/components/ui/sheet"
|
} from "@/shared/components/ui/sheet"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import type { Role } from "@/shared/types/permissions"
|
|
||||||
|
|
||||||
type SidebarContextType = {
|
type SidebarContextType = {
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
setExpanded: (expanded: boolean) => void
|
setExpanded: (expanded: boolean) => void
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
currentRole: Role | null
|
|
||||||
setCurrentRole: (role: Role | null) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
||||||
@@ -41,8 +38,6 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
|
|||||||
const [expanded, setExpanded] = React.useState(true)
|
const [expanded, setExpanded] = React.useState(true)
|
||||||
const [isMobile, setIsMobile] = React.useState(false)
|
const [isMobile, setIsMobile] = React.useState(false)
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
// null 表示自动检测(按现有优先级 admin > student > parent > teacher)
|
|
||||||
const [currentRole, setCurrentRole] = React.useState<Role | null>(null)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -67,7 +62,7 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider
|
<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">
|
<div className="flex h-screen overflow-hidden w-full flex-col md:flex-row bg-background">
|
||||||
{/* Mobile Trigger & Sheet */}
|
{/* Mobile Trigger & Sheet */}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "@/shared/components/ui/dropdown-menu"
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import { GlobalSearch } from "@/shared/components/global-search"
|
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 { useSidebar } from "./sidebar-provider"
|
||||||
import { NAV_CONFIG } from "../config/navigation"
|
import { NAV_CONFIG } from "../config/navigation"
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
BookMarked,
|
BookMarked,
|
||||||
BookCopy,
|
BookCopy,
|
||||||
Files,
|
Files,
|
||||||
|
BookX,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
@@ -127,6 +128,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/admin/files",
|
href: "/admin/files",
|
||||||
permission: Permissions.FILE_READ,
|
permission: Permissions.FILE_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "错题分析",
|
||||||
|
icon: BookX,
|
||||||
|
href: "/admin/error-book",
|
||||||
|
permission: Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Audit Logs",
|
title: "Audit Logs",
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
@@ -242,6 +249,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/teacher/diagnostic",
|
href: "/teacher/diagnostic",
|
||||||
permission: Permissions.DIAGNOSTIC_READ,
|
permission: Permissions.DIAGNOSTIC_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "错题分析",
|
||||||
|
icon: BookX,
|
||||||
|
href: "/teacher/error-book",
|
||||||
|
permission: Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "选修课",
|
title: "选修课",
|
||||||
icon: BookMarked,
|
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: "成绩分析", 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,
|
...COMMON_NAV_ITEMS,
|
||||||
],
|
],
|
||||||
teaching_head: [
|
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: "成绩分析", 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,
|
...COMMON_NAV_ITEMS,
|
||||||
],
|
],
|
||||||
student: [
|
student: [
|
||||||
@@ -379,6 +404,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/student/diagnostic",
|
href: "/student/diagnostic",
|
||||||
permission: Permissions.DIAGNOSTIC_READ,
|
permission: Permissions.DIAGNOSTIC_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "错题本",
|
||||||
|
icon: BookX,
|
||||||
|
href: "/student/error-book",
|
||||||
|
permission: Permissions.ERROR_BOOK_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Electives",
|
title: "Electives",
|
||||||
icon: BookMarked,
|
icon: BookMarked,
|
||||||
@@ -405,6 +436,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
|||||||
href: "/parent/attendance",
|
href: "/parent/attendance",
|
||||||
permission: Permissions.ATTENDANCE_READ,
|
permission: Permissions.ATTENDANCE_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "错题本",
|
||||||
|
icon: BookX,
|
||||||
|
href: "/parent/error-book",
|
||||||
|
permission: Permissions.ERROR_BOOK_READ,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Leave Request",
|
title: "Leave Request",
|
||||||
icon: CalendarRange,
|
icon: CalendarRange,
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import {
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
requirePermission,
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
PermissionDeniedError,
|
|
||||||
} from "@/shared/lib/auth-guard"
|
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
@@ -91,11 +89,7 @@ export async function recordProctoringEventAction(
|
|||||||
|
|
||||||
return successState({ id: event.id }, "Event recorded")
|
return successState({ id: event.id }, "Event recorded")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
return handleActionError(error)
|
||||||
return failState<{ id: string }>(error.message)
|
|
||||||
}
|
|
||||||
console.error("recordProctoringEventAction error:", error)
|
|
||||||
return failState<{ id: string }>("Failed to record proctoring event")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +124,6 @@ export async function getProctoringDashboardAction(
|
|||||||
recentEvents,
|
recentEvents,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
return handleActionError(error)
|
||||||
return failState<ProctoringDashboardData>(error.message)
|
|
||||||
}
|
|
||||||
console.error("getProctoringDashboardAction error:", error)
|
|
||||||
return failState<ProctoringDashboardData>("Failed to load proctoring dashboard")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PROCTORING_EVENT_LABELS } from "../types"
|
|||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟
|
||||||
const REPORT_THROTTLE_MS = 1500 // 同类事件最小上报间隔
|
const REPORT_THROTTLE_MS = 1500 // 同类事件最小上报间隔
|
||||||
|
const ACTIVITY_THROTTLE_MS = 1000 // 用户活动事件节流间隔(mousemove 等高频事件)
|
||||||
|
|
||||||
type AntiCheatMonitorProps = {
|
type AntiCheatMonitorProps = {
|
||||||
examId: string
|
examId: string
|
||||||
@@ -144,7 +145,12 @@ export function AntiCheatMonitor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastActivityAt = 0
|
||||||
const handleUserActivity = () => {
|
const handleUserActivity = () => {
|
||||||
|
// 节流:mousemove 等高频事件每秒最多触发一次 resetIdleTimer
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastActivityAt < ACTIVITY_THROTTLE_MS) return
|
||||||
|
lastActivityAt = now
|
||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getExamTitleById,
|
getExamTitleById,
|
||||||
} from "@/modules/exams/data-access"
|
} from "@/modules/exams/data-access"
|
||||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ProctoringEvent,
|
ProctoringEvent,
|
||||||
@@ -123,10 +124,20 @@ export const getProctoringEvents = cache(
|
|||||||
conditions.push(eq(examProctoringEvents.eventType, filters.eventType))
|
conditions.push(eq(examProctoringEvents.eventType, filters.eventType))
|
||||||
}
|
}
|
||||||
if (filters?.startedAt) {
|
if (filters?.startedAt) {
|
||||||
conditions.push(gte(examProctoringEvents.occurredAt, new Date(filters.startedAt)))
|
conditions.push(
|
||||||
|
gte(
|
||||||
|
examProctoringEvents.occurredAt,
|
||||||
|
safeParseDate(filters.startedAt, "开始时间"),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (filters?.endedAt) {
|
if (filters?.endedAt) {
|
||||||
conditions.push(lte(examProctoringEvents.occurredAt, new Date(filters.endedAt)))
|
conditions.push(
|
||||||
|
lte(
|
||||||
|
examProctoringEvents.occurredAt,
|
||||||
|
safeParseDate(filters.endedAt, "结束时间"),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { CreateQuestionSchema } from "./schema"
|
import { CreateQuestionSchema } from "./schema"
|
||||||
import type { CreateQuestionInput } from "./schema"
|
import type { CreateQuestionInput } from "./schema"
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type GetQuestionsParams,
|
type GetQuestionsParams,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { KnowledgePointOption } from "./types"
|
import type { KnowledgePointOption } from "./types"
|
||||||
|
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||||
|
|
||||||
/** Result type of getQuestions (data + meta) */
|
/** Result type of getQuestions (data + meta) */
|
||||||
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
||||||
@@ -35,7 +36,7 @@ export async function createQuestionAction(
|
|||||||
if (formData instanceof FormData) {
|
if (formData instanceof FormData) {
|
||||||
const jsonString = formData.get("json")
|
const jsonString = formData.get("json")
|
||||||
if (typeof jsonString === "string") {
|
if (typeof jsonString === "string") {
|
||||||
rawInput = JSON.parse(jsonString) as unknown
|
rawInput = safeJsonParse<unknown>(jsonString, "题目内容格式无效")
|
||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||||
}
|
}
|
||||||
@@ -53,29 +54,17 @@ export async function createQuestionAction(
|
|||||||
|
|
||||||
const input = validatedFields.data
|
const input = validatedFields.data
|
||||||
|
|
||||||
await createQuestionWithRelations(input, ctx.userId)
|
const questionId = await createQuestionWithRelations(input, ctx.userId)
|
||||||
|
|
||||||
revalidatePath("/teacher/questions")
|
revalidatePath("/teacher/questions")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Question created successfully",
|
message: "Question created successfully",
|
||||||
|
data: questionId,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
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",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +72,7 @@ const UpdateQuestionSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
|
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
|
||||||
difficulty: z.number().min(1).max(5),
|
difficulty: z.number().min(1).max(5),
|
||||||
content: z.unknown(),
|
content: z.record(z.string(), z.unknown()),
|
||||||
knowledgePointIds: z.array(z.string()).optional(),
|
knowledgePointIds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,7 +89,7 @@ export async function updateQuestionAction(
|
|||||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
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) {
|
if (!parsed.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -115,15 +104,9 @@ export async function updateQuestionAction(
|
|||||||
|
|
||||||
revalidatePath("/teacher/questions")
|
revalidatePath("/teacher/questions")
|
||||||
|
|
||||||
return { success: true, message: "Question updated successfully" }
|
return { success: true, message: "Question updated successfully", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
if (e instanceof Error) {
|
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
return { success: false, message: "An unexpected error occurred" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,15 +127,9 @@ export async function deleteQuestionAction(
|
|||||||
|
|
||||||
revalidatePath("/teacher/questions")
|
revalidatePath("/teacher/questions")
|
||||||
|
|
||||||
return { success: true, message: "Question deleted successfully" }
|
return { success: true, message: "Question deleted successfully", data: questionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
if (e instanceof Error) {
|
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
return { success: false, message: "Failed to delete question" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +141,7 @@ export async function getQuestionsAction(
|
|||||||
const data = await getQuestions(params)
|
const data = await getQuestions(params)
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
const message = e instanceof Error ? e.message : "Failed to fetch questions"
|
|
||||||
return { success: false, message }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +153,6 @@ export async function getKnowledgePointOptionsAction(): Promise<
|
|||||||
const data = await getKnowledgePointOptions()
|
const data = await getKnowledgePointOptions()
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options"
|
|
||||||
return { success: false, message }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,27 +19,17 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
|
||||||
} from "@/shared/components/ui/form"
|
} from "@/shared/components/ui/form"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import {
|
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||||
Select,
|
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
|
||||||
import { BaseQuestionSchema } from "../schema"
|
import { BaseQuestionSchema } from "../schema"
|
||||||
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
||||||
import { toast } from "sonner"
|
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({
|
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||||
difficulty: z.number().min(1).max(5),
|
difficulty: z.number().min(1).max(5),
|
||||||
@@ -112,10 +102,14 @@ export function CreateQuestionDialog({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const isEdit = !!initialData
|
const isEdit = !!initialData
|
||||||
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
|
|
||||||
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
||||||
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
|
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>({
|
const form = useForm<QuestionFormValues>({
|
||||||
resolver: zodResolver(QuestionFormSchema),
|
resolver: zodResolver(QuestionFormSchema),
|
||||||
@@ -156,21 +150,6 @@ export function CreateQuestionDialog({
|
|||||||
}
|
}
|
||||||
}, [initialData, form, open, defaultContent, defaultType])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
@@ -269,7 +248,8 @@ export function CreateQuestionDialog({
|
|||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Operation failed")
|
toast.error(res.message || "Operation failed")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("Failed to submit question", e)
|
||||||
toast.error("Unexpected error")
|
toast.error("Unexpected error")
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
@@ -289,79 +269,43 @@ export function CreateQuestionDialog({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<SelectField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="type"
|
name="type"
|
||||||
render={({ field }) => (
|
label="Question Type"
|
||||||
<FormItem>
|
placeholder="Select type"
|
||||||
<FormLabel>Question Type</FormLabel>
|
options={[
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
{ value: "single_choice", label: "Single Choice" },
|
||||||
<FormControl>
|
{ value: "multiple_choice", label: "Multiple Choice" },
|
||||||
<SelectTrigger>
|
{ value: "judgment", label: "True/False" },
|
||||||
<SelectValue placeholder="Select type" />
|
{ value: "text", label: "Short Answer" },
|
||||||
</SelectTrigger>
|
{ value: "composite", label: "Composite" },
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
<SelectField
|
||||||
<FormField
|
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="difficulty"
|
name="difficulty"
|
||||||
render={({ field }) => (
|
label="Difficulty (1-5)"
|
||||||
<FormItem>
|
placeholder="Select difficulty"
|
||||||
<FormLabel>Difficulty (1-5)</FormLabel>
|
toSelectValue={(v) => String(v)}
|
||||||
<Select
|
fromSelectValue={(val) => {
|
||||||
value={String(field.value)}
|
const n = parseInt(val, 10)
|
||||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
return Number.isFinite(n) ? n : 1
|
||||||
>
|
}}
|
||||||
<FormControl>
|
options={[1, 2, 3, 4, 5].map((level) => ({
|
||||||
<SelectTrigger>
|
value: String(level),
|
||||||
<SelectValue placeholder="Select difficulty" />
|
label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`,
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<TextareaField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
label="Question Content"
|
||||||
<FormItem>
|
placeholder="Enter the question text here..."
|
||||||
<FormLabel>Question Content</FormLabel>
|
description="Supports basic text. Rich text editor coming soon."
|
||||||
<FormControl>
|
textareaClassName="min-h-[100px]"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -444,7 +388,7 @@ export function CreateQuestionDialog({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{form.watch("options")?.map((option, index) => (
|
{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">
|
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,15 +48,20 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
const copyId = () => {
|
const copyId = () => {
|
||||||
navigator.clipboard.writeText(question.id)
|
try {
|
||||||
toast.success("Question ID copied to clipboard")
|
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 () => {
|
const handleDelete = async () => {
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("id", question.id)
|
fd.set("questionId", question.id)
|
||||||
const res = await deleteQuestionAction(undefined, fd)
|
const res = await deleteQuestionAction(undefined, fd)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success("Question deleted successfully")
|
toast.success("Question deleted successfully")
|
||||||
@@ -65,7 +70,8 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
|||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to delete question")
|
toast.error(res.message || "Failed to delete question")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("Failed to delete question", e)
|
||||||
toast.error("Failed to delete question")
|
toast.error("Failed to delete question")
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
|
|||||||
@@ -4,41 +4,12 @@ import { ColumnDef } from "@tanstack/react-table"
|
|||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
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"
|
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>[] = [
|
export const columns: ColumnDef<Question>[] = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
@@ -63,11 +34,15 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: "Type",
|
header: "Type",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const type = row.getValue("type") as QuestionType
|
const type = row.original.type
|
||||||
return (
|
return (
|
||||||
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
|
<StatusBadge
|
||||||
{getTypeLabel(type)}
|
status={type}
|
||||||
</Badge>
|
variantMap={QUESTION_TYPE_VARIANT}
|
||||||
|
labelMap={QUESTION_TYPE_LABEL}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
capitalize={false}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -75,7 +50,7 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "content",
|
accessorKey: "content",
|
||||||
header: "Content",
|
header: "Content",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const content = row.getValue("content") as unknown
|
const content = row.original.content
|
||||||
let preview = ""
|
let preview = ""
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
preview = content
|
preview = content
|
||||||
@@ -100,7 +75,7 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "difficulty",
|
accessorKey: "difficulty",
|
||||||
header: "Difficulty",
|
header: "Difficulty",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const diff = row.getValue("difficulty") as number
|
const diff = row.original.difficulty
|
||||||
const label =
|
const label =
|
||||||
diff === 1
|
diff === 1
|
||||||
? "Easy"
|
? "Easy"
|
||||||
@@ -148,9 +123,14 @@ export const columns: ColumnDef<Question>[] = [
|
|||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Created",
|
header: "Created",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
const createdAt = row.original.createdAt
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export const getQuestions = cache(async ({
|
|||||||
type,
|
type,
|
||||||
difficulty,
|
difficulty,
|
||||||
}: GetQuestionsParams = {}) => {
|
}: 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[] = [];
|
const conditions: SQL[] = [];
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export const getQuestions = cache(async ({
|
|||||||
|
|
||||||
const rows = await db.query.questions.findMany({
|
const rows = await db.query.questions.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
limit: pageSize,
|
limit: safePageSize,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: [desc(questions.createdAt)],
|
orderBy: [desc(questions.createdAt)],
|
||||||
with: {
|
with: {
|
||||||
@@ -100,7 +102,7 @@ export const getQuestions = cache(async ({
|
|||||||
image: true,
|
image: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
children: true,
|
children: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,10 +134,10 @@ export const getQuestions = cache(async ({
|
|||||||
return mapped;
|
return mapped;
|
||||||
}),
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
page,
|
page: safePage,
|
||||||
pageSize,
|
pageSize: safePageSize,
|
||||||
total,
|
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
|
const children = await tx
|
||||||
.select({ id: questions.id })
|
.select({ id: questions.id })
|
||||||
.from(questions)
|
.from(questions)
|
||||||
.where(eq(questions.parentId, questionId));
|
.where(eq(questions.parentId, questionId));
|
||||||
|
|
||||||
for (const child of children) {
|
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));
|
await tx.delete(questions).where(eq(questions.id, questionId));
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
|
||||||
import { QuestionTypeEnum } from "./schema"
|
import { QuestionTypeEnum } from "./schema"
|
||||||
|
|
||||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>
|
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 {
|
export interface Question {
|
||||||
id: string
|
id: string
|
||||||
content: unknown
|
content: unknown
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type GradeOption = {
|
|||||||
* 新增学科只需在此处添加一条记录,所有 Select/筛选/表单/设置弹窗自动同步。
|
* 新增学科只需在此处添加一条记录,所有 Select/筛选/表单/设置弹窗自动同步。
|
||||||
*/
|
*/
|
||||||
export const SUBJECTS: readonly SubjectOption[] = [
|
export const SUBJECTS: readonly SubjectOption[] = [
|
||||||
|
{ value: "Chinese", labelKey: "chinese" },
|
||||||
{ value: "Mathematics", labelKey: "mathematics" },
|
{ value: "Mathematics", labelKey: "mathematics" },
|
||||||
{ value: "Physics", labelKey: "physics" },
|
{ value: "Physics", labelKey: "physics" },
|
||||||
{ value: "Chemistry", labelKey: "chemistry" },
|
{ value: "Chemistry", labelKey: "chemistry" },
|
||||||
@@ -34,6 +35,8 @@ export const SUBJECTS: readonly SubjectOption[] = [
|
|||||||
* 年级列表。
|
* 年级列表。
|
||||||
*/
|
*/
|
||||||
export const GRADES: readonly GradeOption[] = [
|
export const GRADES: readonly GradeOption[] = [
|
||||||
|
{ value: "Grade 1", labelKey: "grade1" },
|
||||||
|
{ value: "Grade 2", labelKey: "grade2" },
|
||||||
{ value: "Grade 7", labelKey: "grade7" },
|
{ value: "Grade 7", labelKey: "grade7" },
|
||||||
{ value: "Grade 8", labelKey: "grade8" },
|
{ value: "Grade 8", labelKey: "grade8" },
|
||||||
{ value: "Grade 9", labelKey: "grade9" },
|
{ value: "Grade 9", labelKey: "grade9" },
|
||||||
@@ -47,6 +50,8 @@ export const GRADES: readonly GradeOption[] = [
|
|||||||
* key 必须与 SUBJECTS 中的 value 一致。
|
* key 必须与 SUBJECTS 中的 value 一致。
|
||||||
*/
|
*/
|
||||||
export const SUBJECT_COLORS: Record<string, string> = {
|
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:
|
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",
|
"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:
|
Physics:
|
||||||
@@ -73,3 +78,19 @@ export const DEFAULT_SUBJECT_COLOR =
|
|||||||
export function getSubjectColor(subject: string): string {
|
export function getSubjectColor(subject: string): string {
|
||||||
return SUBJECT_COLORS[subject] ?? DEFAULT_SUBJECT_COLOR
|
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)
|
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 = {
|
export type UsersDashboardStats = {
|
||||||
userCount: number
|
userCount: number
|
||||||
activeSessionsCount: number
|
activeSessionsCount: number
|
||||||
|
|||||||
Reference in New Issue
Block a user