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

- Update attendance components and data-access for record management

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

- Update auth login and register forms

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

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

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

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

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

- Update layout sidebar, header, and navigation config

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

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

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

- Update textbooks constants and text-selection hook

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, [])

View File

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

View File

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

View File

@@ -402,6 +402,28 @@ export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id:
return rows.map((r) => ({ id: r.id, name: r.name })) 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 []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,14 +76,14 @@ 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 ?? ""}

View File

@@ -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) => (
@@ -197,7 +197,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
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>

View File

@@ -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({
@@ -141,7 +165,7 @@ function SortableItem({
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")}>
@@ -234,6 +260,7 @@ function StructureRenderer({ nodes, ...props }: {
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}>
@@ -303,27 +331,30 @@ 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)
@@ -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
@@ -561,11 +592,11 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
// 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>
) )

View File

@@ -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 || "未命名题目"}

View File

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

View File

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

View File

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

View File

@@ -265,6 +265,26 @@ export const getFileStats = cache(
}, },
) )
/**
* 按 URL 查询文件附件(用于头像等场景的旧文件清理)
*/
export const getFileByUrl = cache(
async (url: string): Promise<FileAttachment | null> => {
try {
const [row] = await db
.select()
.from(fileAttachments)
.where(eq(fileAttachments.url, url))
.limit(1)
return row ? mapRow(row) : null
} catch (error) {
console.error("getFileByUrl failed:", error)
return null
}
},
)
/** /**
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/ */

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}
}
/** /**
* 格式化学生答案为可读字符串 * 格式化学生答案为可读字符串
*/ */

View File

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

View File

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

View File

@@ -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 ?? []
@@ -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>

View File

@@ -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 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {
@@ -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));

View File

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

View File

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

View File

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