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

@@ -1,6 +1,6 @@
"use server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { CreateQuestionSchema } from "./schema"
import type { CreateQuestionInput } from "./schema"
@@ -16,6 +16,7 @@ import {
type GetQuestionsParams,
} from "./data-access"
import type { KnowledgePointOption } from "./types"
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
/** Result type of getQuestions (data + meta) */
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
@@ -35,7 +36,7 @@ export async function createQuestionAction(
if (formData instanceof FormData) {
const jsonString = formData.get("json")
if (typeof jsonString === "string") {
rawInput = JSON.parse(jsonString) as unknown
rawInput = safeJsonParse<unknown>(jsonString, "题目内容格式无效")
} else {
return { success: false, message: "Invalid submission format. Expected JSON." }
}
@@ -53,29 +54,17 @@ export async function createQuestionAction(
const input = validatedFields.data
await createQuestionWithRelations(input, ctx.userId)
const questionId = await createQuestionWithRelations(input, ctx.userId)
revalidatePath("/teacher/questions")
return {
success: true,
message: "Question created successfully",
data: questionId,
}
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) {
return {
success: false,
message: e.message || "Database error occurred",
}
}
return {
success: false,
message: "An unexpected error occurred",
}
return handleActionError(e)
}
}
@@ -83,7 +72,7 @@ const UpdateQuestionSchema = z.object({
id: z.string().min(1),
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
difficulty: z.number().min(1).max(5),
content: z.unknown(),
content: z.record(z.string(), z.unknown()),
knowledgePointIds: z.array(z.string()).optional(),
})
@@ -100,7 +89,7 @@ export async function updateQuestionAction(
return { success: false, message: "Invalid submission format. Expected JSON." }
}
const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString))
const parsed = UpdateQuestionSchema.safeParse(safeJsonParse<unknown>(jsonString, "题目内容格式无效"))
if (!parsed.success) {
return {
success: false,
@@ -115,15 +104,9 @@ export async function updateQuestionAction(
revalidatePath("/teacher/questions")
return { success: true, message: "Question updated successfully" }
return { success: true, message: "Question updated successfully", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) {
return { success: false, message: e.message }
}
return { success: false, message: "An unexpected error occurred" }
return handleActionError(e)
}
}
@@ -144,15 +127,9 @@ export async function deleteQuestionAction(
revalidatePath("/teacher/questions")
return { success: true, message: "Question deleted successfully" }
return { success: true, message: "Question deleted successfully", data: questionId }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) {
return { success: false, message: e.message }
}
return { success: false, message: "Failed to delete question" }
return handleActionError(e)
}
}
@@ -164,11 +141,7 @@ export async function getQuestionsAction(
const data = await getQuestions(params)
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "Failed to fetch questions"
return { success: false, message }
return handleActionError(e)
}
}
@@ -180,10 +153,6 @@ export async function getKnowledgePointOptionsAction(): Promise<
const data = await getKnowledgePointOptions()
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options"
return { success: false, message }
return handleActionError(e)
}
}

View File

@@ -19,27 +19,17 @@ import {
} from "@/shared/components/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { SelectField } from "@/shared/components/form-fields/select-field"
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
import { BaseQuestionSchema } from "../schema"
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
import { toast } from "sonner"
import { KnowledgePointOption, Question } from "../types"
import { Question } from "../types"
import { useActionQuery } from "@/shared/hooks/use-action-query"
const QuestionFormSchema = BaseQuestionSchema.extend({
difficulty: z.number().min(1).max(5),
@@ -112,10 +102,14 @@ export function CreateQuestionDialog({
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false)
const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery(
() => getKnowledgePointOptionsAction(),
{ deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" }
)
const knowledgePointOptions = knowledgePointOptionsData ?? []
const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema),
@@ -156,21 +150,6 @@ export function CreateQuestionDialog({
}
}, [initialData, form, open, defaultContent, defaultType])
useEffect(() => {
if (!open) return
setIsLoadingKnowledgePoints(true)
getKnowledgePointOptionsAction()
.then((result) => {
setKnowledgePointOptions(result.success && result.data ? result.data : [])
})
.catch(() => {
toast.error("Failed to load knowledge points")
})
.finally(() => {
setIsLoadingKnowledgePoints(false)
})
}, [open])
useEffect(() => {
if (!open) return
if (initialData) {
@@ -269,7 +248,8 @@ export function CreateQuestionDialog({
} else {
toast.error(res.message || "Operation failed")
}
} catch {
} catch (e) {
console.error("Failed to submit question", e)
toast.error("Unexpected error")
} finally {
setIsPending(false)
@@ -289,79 +269,43 @@ export function CreateQuestionDialog({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
<SelectField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Question Type</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
label="Question Type"
placeholder="Select type"
options={[
{ value: "single_choice", label: "Single Choice" },
{ value: "multiple_choice", label: "Multiple Choice" },
{ value: "judgment", label: "True/False" },
{ value: "text", label: "Short Answer" },
{ value: "composite", label: "Composite" },
]}
/>
<FormField
<SelectField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty (1-5)</FormLabel>
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(parseInt(val))}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
</FormControl>
<SelectContent>
{[1, 2, 3, 4, 5].map((level) => (
<SelectItem key={level} value={String(level)}>
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
label="Difficulty (1-5)"
placeholder="Select difficulty"
toSelectValue={(v) => String(v)}
fromSelectValue={(val) => {
const n = parseInt(val, 10)
return Number.isFinite(n) ? n : 1
}}
options={[1, 2, 3, 4, 5].map((level) => ({
value: String(level),
label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`,
}))}
/>
</div>
<FormField
<TextareaField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Question Content</FormLabel>
<FormControl>
<Textarea
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>

View File

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

View File

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

View File

@@ -37,7 +37,9 @@ export const getQuestions = cache(async ({
type,
difficulty,
}: GetQuestionsParams = {}) => {
const offset = (page - 1) * pageSize;
const safePage = typeof page === "number" && page >= 1 ? page : 1
const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50
const offset = (safePage - 1) * safePageSize;
const conditions: SQL[] = [];
@@ -84,7 +86,7 @@ export const getQuestions = cache(async ({
const rows = await db.query.questions.findMany({
where: whereClause,
limit: pageSize,
limit: safePageSize,
offset: offset,
orderBy: [desc(questions.createdAt)],
with: {
@@ -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));

View File

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