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:
@@ -1,6 +1,6 @@
|
||||
"use server"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { CreateQuestionSchema } from "./schema"
|
||||
import type { CreateQuestionInput } from "./schema"
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type GetQuestionsParams,
|
||||
} from "./data-access"
|
||||
import type { KnowledgePointOption } from "./types"
|
||||
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||
|
||||
/** Result type of getQuestions (data + meta) */
|
||||
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
||||
@@ -35,7 +36,7 @@ export async function createQuestionAction(
|
||||
if (formData instanceof FormData) {
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString === "string") {
|
||||
rawInput = JSON.parse(jsonString) as unknown
|
||||
rawInput = safeJsonParse<unknown>(jsonString, "题目内容格式无效")
|
||||
} else {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||
}
|
||||
@@ -53,29 +54,17 @@ export async function createQuestionAction(
|
||||
|
||||
const input = validatedFields.data
|
||||
|
||||
await createQuestionWithRelations(input, ctx.userId)
|
||||
const questionId = await createQuestionWithRelations(input, ctx.userId)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Question created successfully",
|
||||
data: questionId,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message || "Database error occurred",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "An unexpected error occurred",
|
||||
}
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +72,7 @@ const UpdateQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.unknown(),
|
||||
content: z.record(z.string(), z.unknown()),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
@@ -100,7 +89,7 @@ export async function updateQuestionAction(
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||
}
|
||||
|
||||
const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString))
|
||||
const parsed = UpdateQuestionSchema.safeParse(safeJsonParse<unknown>(jsonString, "题目内容格式无效"))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -115,15 +104,9 @@ export async function updateQuestionAction(
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
|
||||
return { success: true, message: "Question updated successfully" }
|
||||
return { success: true, message: "Question updated successfully", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
return { success: false, message: "An unexpected error occurred" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,15 +127,9 @@ export async function deleteQuestionAction(
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
|
||||
return { success: true, message: "Question deleted successfully" }
|
||||
return { success: true, message: "Question deleted successfully", data: questionId }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
return { success: false, message: "Failed to delete question" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +141,7 @@ export async function getQuestionsAction(
|
||||
const data = await getQuestions(params)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch questions"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,10 +153,6 @@ export async function getKnowledgePointOptionsAction(): Promise<
|
||||
const data = await getKnowledgePointOptions()
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,27 +19,17 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
||||
import { BaseQuestionSchema } from "../schema"
|
||||
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { KnowledgePointOption, Question } from "../types"
|
||||
import { Question } from "../types"
|
||||
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
||||
|
||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||
difficulty: z.number().min(1).max(5),
|
||||
@@ -112,10 +102,14 @@ export function CreateQuestionDialog({
|
||||
const router = useRouter()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const isEdit = !!initialData
|
||||
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
|
||||
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
||||
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
|
||||
const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false)
|
||||
|
||||
const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery(
|
||||
() => getKnowledgePointOptionsAction(),
|
||||
{ deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" }
|
||||
)
|
||||
const knowledgePointOptions = knowledgePointOptionsData ?? []
|
||||
|
||||
const form = useForm<QuestionFormValues>({
|
||||
resolver: zodResolver(QuestionFormSchema),
|
||||
@@ -156,21 +150,6 @@ export function CreateQuestionDialog({
|
||||
}
|
||||
}, [initialData, form, open, defaultContent, defaultType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setIsLoadingKnowledgePoints(true)
|
||||
getKnowledgePointOptionsAction()
|
||||
.then((result) => {
|
||||
setKnowledgePointOptions(result.success && result.data ? result.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to load knowledge points")
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingKnowledgePoints(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initialData) {
|
||||
@@ -269,7 +248,8 @@ export function CreateQuestionDialog({
|
||||
} else {
|
||||
toast.error(res.message || "Operation failed")
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("Failed to submit question", e)
|
||||
toast.error("Unexpected error")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
@@ -289,79 +269,43 @@ export function CreateQuestionDialog({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
<SelectField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Type</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Question Type"
|
||||
placeholder="Select type"
|
||||
options={[
|
||||
{ value: "single_choice", label: "Single Choice" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice" },
|
||||
{ value: "judgment", label: "True/False" },
|
||||
{ value: "text", label: "Short Answer" },
|
||||
{ value: "composite", label: "Composite" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
<SelectField
|
||||
control={form.control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty (1-5)</FormLabel>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<SelectItem key={level} value={String(level)}>
|
||||
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Difficulty (1-5)"
|
||||
placeholder="Select difficulty"
|
||||
toSelectValue={(v) => String(v)}
|
||||
fromSelectValue={(val) => {
|
||||
const n = parseInt(val, 10)
|
||||
return Number.isFinite(n) ? n : 1
|
||||
}}
|
||||
options={[1, 2, 3, 4, 5].map((level) => ({
|
||||
value: String(level),
|
||||
label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
<TextareaField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Content</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the question text here..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supports basic text. Rich text editor coming soon.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Question Content"
|
||||
placeholder="Enter the question text here..."
|
||||
description="Supports basic text. Rich text editor coming soon."
|
||||
textareaClassName="min-h-[100px]"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -444,7 +388,7 @@ export function CreateQuestionDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
{form.watch("options")?.map((option, index) => (
|
||||
<div key={option.value || index} className="flex items-center gap-2">
|
||||
<div key={option.value || `option-${index}`} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
@@ -48,15 +48,20 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success("Question ID copied to clipboard")
|
||||
try {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success("Question ID copied to clipboard")
|
||||
} catch (e) {
|
||||
console.error("Failed to copy question ID to clipboard", e)
|
||||
toast.error("Failed to copy question ID")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set("id", question.id)
|
||||
fd.set("questionId", question.id)
|
||||
const res = await deleteQuestionAction(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success("Question deleted successfully")
|
||||
@@ -65,7 +70,8 @@ export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete question")
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("Failed to delete question", e)
|
||||
toast.error("Failed to delete question")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
|
||||
@@ -4,41 +4,12 @@ import { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Question, QuestionType } from "../types"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { Question } from "../types"
|
||||
import { QUESTION_TYPE_VARIANT, QUESTION_TYPE_LABEL } from "../types"
|
||||
import { QuestionActions } from "./question-actions"
|
||||
|
||||
const getTypeColor = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
return "default"
|
||||
case "multiple_choice":
|
||||
return "secondary"
|
||||
case "judgment":
|
||||
return "outline"
|
||||
case "text":
|
||||
return "secondary"
|
||||
default:
|
||||
return "secondary"
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
return "Single Choice"
|
||||
case "multiple_choice":
|
||||
return "Multiple Choice"
|
||||
case "judgment":
|
||||
return "True/False"
|
||||
case "text":
|
||||
return "Short Answer"
|
||||
case "composite":
|
||||
return "Composite"
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Question>[] = [
|
||||
{
|
||||
id: "select",
|
||||
@@ -63,11 +34,15 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("type") as QuestionType
|
||||
const type = row.original.type
|
||||
return (
|
||||
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
|
||||
{getTypeLabel(type)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
status={type}
|
||||
variantMap={QUESTION_TYPE_VARIANT}
|
||||
labelMap={QUESTION_TYPE_LABEL}
|
||||
className="whitespace-nowrap"
|
||||
capitalize={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
@@ -75,7 +50,7 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "content",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const content = row.getValue("content") as unknown
|
||||
const content = row.original.content
|
||||
let preview = ""
|
||||
if (typeof content === "string") {
|
||||
preview = content
|
||||
@@ -100,7 +75,7 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.getValue("difficulty") as number
|
||||
const diff = row.original.difficulty
|
||||
const label =
|
||||
diff === 1
|
||||
? "Easy"
|
||||
@@ -148,9 +123,14 @@ export const columns: ColumnDef<Question>[] = [
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||
{createdAt instanceof Date
|
||||
? formatDate(createdAt)
|
||||
: typeof createdAt === "string"
|
||||
? formatDate(createdAt)
|
||||
: "—"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -37,7 +37,9 @@ export const getQuestions = cache(async ({
|
||||
type,
|
||||
difficulty,
|
||||
}: GetQuestionsParams = {}) => {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const safePage = typeof page === "number" && page >= 1 ? page : 1
|
||||
const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
@@ -84,7 +86,7 @@ export const getQuestions = cache(async ({
|
||||
|
||||
const rows = await db.query.questions.findMany({
|
||||
where: whereClause,
|
||||
limit: pageSize,
|
||||
limit: safePageSize,
|
||||
offset: offset,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
@@ -100,7 +102,7 @@ export const getQuestions = cache(async ({
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
children: true,
|
||||
children: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,10 +134,10 @@ export const getQuestions = cache(async ({
|
||||
return mapped;
|
||||
}),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
totalPages: Math.ceil(total / safePageSize),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -229,14 +231,24 @@ export async function updateQuestionById(
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteQuestionRecursive(tx: Tx, questionId: string): Promise<void> {
|
||||
async function deleteQuestionRecursive(
|
||||
tx: Tx,
|
||||
questionId: string,
|
||||
visited: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
if (visited.has(questionId)) {
|
||||
// 环检测:避免在异常数据(如循环引用)下无限递归
|
||||
return
|
||||
}
|
||||
visited.add(questionId)
|
||||
|
||||
const children = await tx
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(eq(questions.parentId, questionId));
|
||||
|
||||
for (const child of children) {
|
||||
await deleteQuestionRecursive(tx, child.id);
|
||||
await deleteQuestionRecursive(tx, child.id, visited);
|
||||
}
|
||||
|
||||
await tx.delete(questions).where(eq(questions.id, questionId));
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { z } from "zod"
|
||||
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
|
||||
import { QuestionTypeEnum } from "./schema"
|
||||
|
||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>
|
||||
|
||||
/** 题型 → Badge variant 映射 */
|
||||
export const QUESTION_TYPE_VARIANT: StatusVariantMap<QuestionType> = {
|
||||
single_choice: "default",
|
||||
multiple_choice: "secondary",
|
||||
judgment: "outline",
|
||||
text: "secondary",
|
||||
composite: "secondary",
|
||||
}
|
||||
|
||||
/** 题型 → 展示文本映射 */
|
||||
export const QUESTION_TYPE_LABEL: StatusLabelMap<QuestionType> = {
|
||||
single_choice: "Single Choice",
|
||||
multiple_choice: "Multiple Choice",
|
||||
judgment: "True/False",
|
||||
text: "Short Answer",
|
||||
composite: "Composite",
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string
|
||||
content: unknown
|
||||
|
||||
Reference in New Issue
Block a user