refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled

- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验
- UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内
- 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过)
- 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007)
- 项目规则: 架构图优先规则,改码必同步图
- 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫
- 无障碍: skip-link、aria-label、prefers-reduced-motion
- 性能: next/font优化、next/image、代码分割
This commit is contained in:
SpecialX
2026-06-16 23:38:33 +08:00
parent 99f116cb64
commit 125f7ec54c
75 changed files with 9480 additions and 3289 deletions

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getUserProfile } from "@/modules/users/data-access"
import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
@@ -8,14 +8,11 @@ export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const userId = String(session.user.id ?? "").trim()
if (!userId) redirect("/login")
const profile = await getUserProfile(userId)
if (!profile) redirect("/login")
const role = profile.role || "student"
const permissions = session.user.permissions ?? []
const roles = session.user.roles ?? []
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")
if (role === "parent") redirect("/parent/dashboard")
if (permissions.includes(Permissions.SCHOOL_MANAGE)) redirect("/admin/dashboard")
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) redirect("/student/dashboard")
if (roles.includes("parent")) redirect("/parent/dashboard")
redirect("/teacher/dashboard")
}

View File

@@ -9,8 +9,11 @@ export default function DashboardLayout({
}) {
return (
<SidebarProvider sidebar={<AppSidebar />}>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
Skip to main content
</a>
<SiteHeader />
<main className="flex-1 overflow-auto p-6">
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
</SidebarProvider>

View File

@@ -9,6 +9,7 @@ import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getUserProfile } from "@/modules/users/data-access"
import { Permissions } from "@/shared/types/permissions"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -42,9 +43,9 @@ export default async function ProfilePage() {
redirect("/login")
}
const role = userProfile.role || "student"
const isStudent = role === "student"
const isTeacher = role === "teacher"
const permissions = session.user.permissions ?? []
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
const studentData =
isStudent

View File

@@ -5,6 +5,7 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings-
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { getUserProfile } from "@/modules/users/data-access"
import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
@@ -17,11 +18,9 @@ export default async function SettingsPage() {
if (!userProfile) redirect("/login")
const role = userProfile.role || "student"
const permissions = session.user.permissions ?? []
if (role === "admin") return <AdminSettingsView user={userProfile} />
if (role === "student") return <StudentSettingsView user={userProfile} />
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
redirect("/dashboard")
if (permissions.includes(Permissions.SETTINGS_ADMIN)) return <AdminSettingsView user={userProfile} />
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) return <StudentSettingsView user={userProfile} />
return <TeacherSettingsView user={userProfile} />
}

View File

@@ -8,6 +8,7 @@ import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
import { examColumns } from "@/modules/exams/components/exam-columns"
import { ExamFilters } from "@/modules/exams/components/exam-filters"
import { getExams } from "@/modules/exams/data-access"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { FileText, PlusCircle } from "lucide-react"
type SearchParams = { [key: string]: string | string[] | undefined }
@@ -19,6 +20,7 @@ const getParam = (params: SearchParams, key: string) => {
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const { dataScope } = await getAuthContext()
const q = getParam(params, "q")
const status = getParam(params, "status")
@@ -28,6 +30,7 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
q,
status,
difficulty,
scope: dataScope,
})
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))

View File

@@ -2,12 +2,14 @@ import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-a
import { getExams } from "@/modules/exams/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { FileQuestion } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function CreateHomeworkAssignmentPage() {
const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()])
const { dataScope } = await getAuthContext()
const [exams, classes] = await Promise.all([getExams({ scope: dataScope }), getTeacherClasses()])
const options = exams.map((e) => ({ id: e.id, title: e.title }))
return (

View File

@@ -166,6 +166,20 @@
}
}
/* Reduced Motion */
@layer base {
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
/* Base Styles */
@layer base {
* {

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/shared/components/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import { NuqsAdapter } from 'nuqs/adapters/next/app'
@@ -6,6 +7,12 @@ import { AuthSessionProvider } from "@/shared/components/auth-session-provider"
import { OnboardingGate } from "@/shared/components/onboarding-gate"
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "Next_Edu - K12 智慧教务系统",
description: "Enterprise Grade K12 Education Management System",
@@ -19,7 +26,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`antialiased`}
className={`${inter.variable} antialiased font-sans`}
suppressHydrationWarning
>
<ThemeProvider

View File

@@ -1,6 +1,7 @@
import { compare } from "bcryptjs"
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { resolvePermissions } from "@/shared/lib/permissions"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
@@ -64,13 +65,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
const roleNames = roleRows.map((r) => r.name)
const resolvedRole = resolvePrimaryRole(roleNames)
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: resolvedRole,
roles: roleNames,
}
},
}),
@@ -78,11 +81,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.id = (user as { id: string }).id
token.role = normalizeRole((user as { role?: string }).role)
token.name = (user as { name?: string }).name
const u = user as { id: string; role?: string; roles?: string[]; name?: string }
token.id = u.id
token.role = normalizeRole(u.role)
token.name = u.name ?? undefined
// Store all roles (not just primary) and resolved permissions
const allRoles = u.roles ?? [u.role ?? "student"]
token.roles = allRoles
token.permissions = resolvePermissions(allRoles)
}
// Refresh roles/permissions from DB on each JWT refresh
const userId = String(token.id ?? "").trim()
if (userId) {
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
@@ -93,8 +102,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const [fresh, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { name: true },
where: eq(users.id, userId),
columns: { name: true },
}),
db
.select({ name: roles.name })
@@ -104,8 +113,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
])
if (fresh) {
token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
const allRoles = roleRows.map((r) => r.name)
token.role = resolvePrimaryRole(allRoles)
token.name = fresh.name ?? token.name
token.roles = allRoles
token.permissions = resolvePermissions(allRoles)
}
}
@@ -115,6 +127,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (session.user) {
session.user.id = String(token.id ?? "")
session.user.role = normalizeRole(token.role)
session.user.roles = (token.roles ?? []) as string[]
session.user.permissions = (token.permissions ?? []) as typeof token.permissions
if (typeof token.name === "string") {
session.user.name = token.name
}

View File

@@ -1,4 +1,4 @@
import { Exam, ExamSubmission } from "./types"
import { Exam, ExamSubmission } from "../modules/exams/types"
export let MOCK_EXAMS: Exam[] = [
{

View File

@@ -1,4 +1,4 @@
import { Question } from "./types";
import { Question } from "../modules/questions/types";
export const MOCK_QUESTIONS: Question[] = [
{

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { count, desc, eq, gt, inArray } from "drizzle-orm"
import { count, desc, eq, gt, inArray, and } from "drizzle-orm"
import { db } from "@/shared/db"
import {
@@ -18,10 +18,61 @@ import {
usersToRoles,
} from "@/shared/db/schema"
import type { AdminDashboardData } from "./types"
import type { DataScope } from "@/shared/types/permissions"
export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData> => {
export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<AdminDashboardData> => {
const now = new Date()
// Build scope-based conditions for exams
const examConditions = []
const homeworkConditions = []
const submissionConditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
examConditions.push(eq(exams.creatorId, scope.userId))
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
const ownedAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, scope.userId))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
examConditions.push(inArray(exams.gradeId, scope.gradeIds))
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, scope.gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) {
examConditions.push(inArray(exams.gradeId, gradeIds))
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
}
}
const [
activeSessionsRow,
userCountRow,
@@ -48,11 +99,19 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
db.select({ value: count() }).from(questions),
db.select({ value: count() }).from(exams),
db.select({ value: count() }).from(homeworkAssignments),
db.select({ value: count() }).from(homeworkAssignments).where(eq(homeworkAssignments.status, "published")),
db.select({ value: count() }).from(homeworkSubmissions),
db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "submitted")),
db.select({ value: count() }).from(exams).where(examConditions.length ? and(...examConditions) : undefined),
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
db.select({ value: count() }).from(homeworkAssignments).where(
homeworkConditions.length
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
: eq(homeworkAssignments.status, "published")
),
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
db.select({ value: count() }).from(homeworkSubmissions).where(
submissionConditions.length
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
: eq(homeworkSubmissions.status, "submitted")
),
db
.select({
id: users.id,

View File

@@ -2,6 +2,8 @@
import { revalidatePath } from "next/cache"
import { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { z } from "zod"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
@@ -253,53 +255,61 @@ export async function createExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const parsed = ExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
}
const input = parsed.data
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
const description = context.buildDescription()
try {
const user = await getCurrentUser()
await persistExamDraft({
examId: context.examId,
title: input.title,
creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description,
const ctx = await requirePermission(Permissions.EXAM_CREATE)
const rawQuestions = formData.get("questionsJson") as string | null
const parsed = ExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
}
const input = parsed.data
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
const description = context.buildDescription()
try {
await persistExamDraft({
examId: context.examId,
title: input.title,
creatorId: ctx.userId,
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description,
})
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("Database error: Failed to create exam")
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, "Exam created successfully.")
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("Database error: Failed to create exam")
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw error
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, "Exam created successfully.")
}
const AiExamCreateSchema = ExamCreateSchema.extend({
@@ -324,167 +334,193 @@ export async function createAiExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const parsed = AiExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error)
}
const input = parsed.data
if (!rawAiQuestions && !input.aiSourceText) {
return failState<string>("Please analyze and preview before creating")
}
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
const user = await getCurrentUser()
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
rawAiQuestions,
rawStructure,
title: input.title,
subject: context.subjectName,
grade: context.gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
aiSourceText: input.aiSourceText,
aiQuestionCount: input.aiQuestionCount,
aiProviderId: input.aiProviderId,
})
if (!aiDraftResult.ok) {
return failState<string>(aiDraftResult.message)
}
const { generated, structure } = aiDraftResult
const questionCount = generated.length
const description = context.buildDescription({ questionCount })
try {
await persistAiGeneratedExamDraft({
examId: context.examId,
title: input.title,
creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description,
structure,
generated,
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
const rawQuestions = formData.get("questionsJson") as string | null
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const parsed = AiExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error)
}
const input = parsed.data
if (!rawAiQuestions && !input.aiSourceText) {
return failState<string>("Please analyze and preview before creating")
}
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
rawAiQuestions,
rawStructure,
title: input.title,
subject: context.subjectName,
grade: context.gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
aiSourceText: input.aiSourceText,
aiQuestionCount: input.aiQuestionCount,
aiProviderId: input.aiProviderId,
})
if (!aiDraftResult.ok) {
return failState<string>(aiDraftResult.message)
}
const { generated, structure } = aiDraftResult
const questionCount = generated.length
const description = context.buildDescription({ questionCount })
try {
await persistAiGeneratedExamDraft({
examId: context.examId,
title: input.title,
creatorId: ctx.userId,
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description,
structure,
generated,
})
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("Database error: Failed to create exam")
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, "Exam created successfully.")
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("Database error: Failed to create exam")
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw error
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, "Exam created successfully.")
}
export async function previewAiExamAction(
prevState: ActionState<AiPreviewData> | null,
formData: FormData
): Promise<ActionState<AiPreviewData>> {
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
try {
await requirePermission(Permissions.EXAM_AI_GENERATE)
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
if (!sourceText) {
return failState<AiPreviewData>("Please paste the full exam text first", {
aiSourceText: ["Please paste the full exam text first"],
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
if (!sourceText) {
return failState<AiPreviewData>("Please paste the full exam text first", {
aiSourceText: ["Please paste the full exam text first"],
})
}
const parsed = AiExamPreviewSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
aiSourceText: sourceText,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
}
const parsed = AiExamPreviewSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
aiSourceText: sourceText,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<AiPreviewData>(parsed.error)
}
if (!parsed.success) {
return invalidFormState<AiPreviewData>(parsed.error)
const input = parsed.data
const previewRequest = await prepareAiPreviewRequest(input)
const aiDraft = await generateAiPreviewData(previewRequest)
if (!aiDraft.ok) {
return failState<AiPreviewData>(aiDraft.message)
}
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AiPreviewData>(error.message)
}
throw error
}
const input = parsed.data
const previewRequest = await prepareAiPreviewRequest(input)
const aiDraft = await generateAiPreviewData(previewRequest)
if (!aiDraft.ok) {
return failState<AiPreviewData>(aiDraft.message)
}
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
}
export async function regenerateAiQuestionAction(
prevState: ActionState<AiRewriteQuestionData> | null,
formData: FormData
): Promise<ActionState<AiRewriteQuestionData>> {
const parsedInput = parseRegenerateAiQuestionInput(formData)
if (!parsedInput.ok) {
return parsedInput.state
}
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
const originalDifficulty = originalQuestion.difficulty ?? 3
const originalScore = originalQuestion.score ?? 0
try {
const result = await regenerateAiQuestionByInstruction({
instruction,
originalQuestion,
sourceText,
aiProviderId,
})
if (!result.ok) {
return failState<AiRewriteQuestionData>(result.message)
await requirePermission(Permissions.EXAM_AI_GENERATE)
const parsedInput = parseRegenerateAiQuestionInput(formData)
if (!parsedInput.ok) {
return parsedInput.state
}
return successState({
type: result.data.type,
difficulty: result.data.difficulty ?? originalDifficulty,
score: result.data.score ?? originalScore,
content: result.data.content,
})
} catch {
return failState<AiRewriteQuestionData>("AI question format invalid")
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
const originalDifficulty = originalQuestion.difficulty ?? 3
const originalScore = originalQuestion.score ?? 0
try {
const result = await regenerateAiQuestionByInstruction({
instruction,
originalQuestion,
sourceText,
aiProviderId,
})
if (!result.ok) {
return failState<AiRewriteQuestionData>(result.message)
}
return successState({
type: result.data.type,
difficulty: result.data.difficulty ?? originalDifficulty,
score: result.data.score ?? originalScore,
content: result.data.content,
})
} catch {
return failState<AiRewriteQuestionData>("AI question format invalid")
}
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AiRewriteQuestionData>(error.message)
}
throw error
}
}
@@ -506,58 +542,78 @@ export async function updateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson")
const rawStructure = formData.get("structureJson")
const hasQuestions = typeof rawQuestions === "string"
const hasStructure = typeof rawStructure === "string"
const parsed = ExamUpdateSchema.safeParse({
examId: formData.get("examId"),
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
status: formData.get("status") ?? undefined,
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid update data",
useFirstMessage: false,
})
}
const { examId, questions, structure, status } = parsed.data
try {
if (questions) {
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (questions.length > 0) {
await db.insert(examQuestions).values(
questions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
const rawQuestions = formData.get("questionsJson")
const rawStructure = formData.get("structureJson")
const hasQuestions = typeof rawQuestions === "string"
const hasStructure = typeof rawStructure === "string"
const parsed = ExamUpdateSchema.safeParse({
examId: formData.get("examId"),
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
status: formData.get("status") ?? undefined,
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid update data",
useFirstMessage: false,
})
}
const { examId, questions, structure, status } = parsed.data
// Ownership check: non-admin users can only update their own exams
if (ctx.dataScope.type !== "all") {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { creatorId: true },
})
if (!exam || exam.creatorId !== ctx.userId) {
return failState<string>("You can only update exams you created")
}
}
// Prepare update object
const updateData: Partial<typeof exams.$inferInsert> = {}
if (status) updateData.status = status
if (structure !== undefined) updateData.structure = structure
if (Object.keys(updateData).length > 0) {
await db.update(exams).set(updateData).where(eq(exams.id, examId))
try {
if (questions) {
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (questions.length > 0) {
await db.insert(examQuestions).values(
questions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
}
// Prepare update object
const updateData: Partial<typeof exams.$inferInsert> = {}
if (status) updateData.status = status
if (structure !== undefined) updateData.structure = structure
if (Object.keys(updateData).length > 0) {
await db.update(exams).set(updateData).where(eq(exams.id, examId))
}
} catch {
return failState<string>("Database error: Failed to update exam")
}
} catch {
return failState<string>("Database error: Failed to update exam")
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam updated")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw error
}
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam updated")
}
const ExamDeleteSchema = z.object({
@@ -568,28 +624,48 @@ export async function deleteExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const parsed = ExamDeleteSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid delete data",
useFirstMessage: false,
})
}
const { examId } = parsed.data
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch {
return failState<string>("Database error: Failed to delete exam")
const ctx = await requirePermission(Permissions.EXAM_DELETE)
const parsed = ExamDeleteSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid delete data",
useFirstMessage: false,
})
}
const { examId } = parsed.data
// Ownership check: non-admin users can only delete their own exams
if (ctx.dataScope.type !== "all") {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { creatorId: true },
})
if (!exam || exam.creatorId !== ctx.userId) {
return failState<string>("You can only delete exams you created")
}
}
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch {
return failState<string>("Database error: Failed to delete exam")
}
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam deleted")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw error
}
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam deleted")
}
const ExamDuplicateSchema = z.object({
@@ -600,124 +676,157 @@ export async function duplicateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const parsed = ExamDuplicateSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid duplicate data",
useFirstMessage: false,
})
}
const { examId } = parsed.data
const source = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!source) {
return failState<string>("Exam not found")
}
const newExamId = createId()
const user = await getCurrentUser()
try {
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: newExamId,
title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description),
creatorId: user?.id ?? "user_teacher_math",
startTime: null,
endTime: null,
status: "draft",
structure: source.structure,
})
const ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
if (source.questions.length > 0) {
await tx.insert(examQuestions).values(
source.questions.map((q) => ({
examId: newExamId,
questionId: q.questionId,
score: q.score ?? 0,
order: q.order ?? 0,
}))
)
}
const parsed = ExamDuplicateSchema.safeParse({
examId: formData.get("examId"),
})
} catch {
return failState<string>("Database error: Failed to duplicate exam")
if (!parsed.success) {
return invalidFormState<string>(parsed.error, {
fallbackMessage: "Invalid duplicate data",
useFirstMessage: false,
})
}
const { examId } = parsed.data
const source = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!source) {
return failState<string>("Exam not found")
}
const newExamId = createId()
try {
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: newExamId,
title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description),
creatorId: ctx.userId,
startTime: null,
endTime: null,
status: "draft",
structure: source.structure,
})
if (source.questions.length > 0) {
await tx.insert(examQuestions).values(
source.questions.map((q) => ({
examId: newExamId,
questionId: q.questionId,
score: q.score ?? 0,
order: q.order ?? 0,
}))
)
}
})
} catch {
return failState<string>("Database error: Failed to duplicate exam")
}
revalidatePath("/teacher/exams/all")
return successState(newExamId, "Exam duplicated")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw error
}
revalidatePath("/teacher/exams/all")
return successState(newExamId, "Exam duplicated")
}
export async function getExamPreviewAction(
examId: string
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
try {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true
await requirePermission(Permissions.EXAM_READ)
try {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true
}
}
}
}
})
})
if (!exam) {
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
if (!exam) {
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
}
const questions = exam.questions.map((eq) => eq.question)
return successState({
structure: exam.structure,
questions,
})
} catch (error) {
console.error(error)
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
}
const questions = exam.questions.map((eq) => eq.question)
return successState({
structure: exam.structure,
questions,
})
} catch (error) {
console.error(error)
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
if (error instanceof PermissionDeniedError) {
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message)
}
throw error
}
}
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
const allSubjects = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
await requirePermission(Permissions.EXAM_READ)
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
try {
const allSubjects = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
} catch (error) {
console.error("Failed to fetch subjects:", error)
return failState<{ id: string; name: string }[]>("Failed to load subjects")
}
} catch (error) {
console.error("Failed to fetch subjects:", error)
return failState<{ id: string; name: string }[]>("Failed to load subjects")
if (error instanceof PermissionDeniedError) {
return failState<{ id: string; name: string }[]>(error.message)
}
throw error
}
}
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
const allGrades = await db.query.grades.findMany({
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
})
await requirePermission(Permissions.EXAM_READ)
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
try {
const allGrades = await db.query.grades.findMany({
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
})
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
} catch (error) {
console.error("Failed to fetch grades:", error)
return failState<{ id: string; name: string }[]>("Failed to load grades")
}
} catch (error) {
console.error("Failed to fetch grades:", error)
return failState<{ id: string; name: string }[]>("Failed to load grades")
if (error instanceof PermissionDeniedError) {
return failState<{ id: string; name: string }[]>(error.message)
}
throw error
}
}
async function getCurrentUser() {
return { id: "user_teacher_math", role: "teacher" }
}

View File

@@ -155,21 +155,22 @@ export function ExamActions({ exam }: ExamActionsProps) {
return (
<>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
handleView()
}}
title="Preview Exam"
aria-label="Preview exam"
>
<Eye className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>

View File

@@ -0,0 +1,223 @@
"use client"
import type { Control, UseFormReturn } from "react-hook-form"
import { Settings } from "lucide-react"
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription,
} from "@/shared/components/ui/form"
import { Textarea } from "@/shared/components/ui/textarea"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
import type { AiProviderSummary } from "@/modules/settings/actions"
import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types"
import { aiProviderLabels } from "./exam-form-types"
type ExamAiGeneratorProps = {
form: UseFormReturn<ExamFormValues>
control: Control<ExamFormValues>
aiProviders: AiProviderSummary[]
setAiProviders: (providers: AiProviderSummary[]) => void
loadingAiProviders: boolean
providerDialogOpen: boolean
setProviderDialogOpen: (open: boolean) => void
providerDialogKey: number
setProviderDialogKey: (key: number | ((prev: number) => number)) => void
handlePreview: () => void
handleBackgroundPreview: () => void
previewLoading: boolean
previewTasks: PreviewBackgroundTask[]
handleOpenPreviewTask: (taskId: string) => void
activePreviewTaskCount: number
runningPreviewTaskCount: number
queuedPreviewTaskCount: number
}
export function ExamAiGenerator({
form,
control,
aiProviders,
setAiProviders,
loadingAiProviders,
providerDialogOpen,
setProviderDialogOpen,
providerDialogKey,
setProviderDialogKey,
handlePreview,
handleBackgroundPreview,
previewLoading,
previewTasks,
handleOpenPreviewTask,
activePreviewTaskCount,
runningPreviewTaskCount,
queuedPreviewTaskCount,
}: ExamAiGeneratorProps) {
const formatTaskTime = (value: number) => new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
return (
<Card>
<CardHeader>
<CardTitle>AI Generation</CardTitle>
<CardDescription>
Paste the exam text and generate a structured preview.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<FormField
control={control}
name="aiProviderId"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>AI Provider</FormLabel>
<Dialog
open={providerDialogOpen}
onOpenChange={(open) => {
setProviderDialogOpen(open)
if (open) {
setProviderDialogKey((value) => value + 1)
}
}}
>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
<Settings className="mr-1 h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>AI Provider Settings</DialogTitle>
<DialogDescription>
Create a new provider or update existing configuration.
</DialogDescription>
</DialogHeader>
<AiProviderSettingsCard
key={providerDialogKey}
initialMode="new"
onProvidersChanged={(rows) => {
setAiProviders(rows)
const preferred = rows.find((item) => item.isDefault) ?? rows[0]
if (preferred) {
form.setValue("aiProviderId", preferred.id)
}
}}
/>
</DialogContent>
</Dialog>
</div>
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingAiProviders ? "Loading providers..." : "Select provider"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{aiProviders.map((item) => (
<SelectItem key={item.id} value={item.id}>
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? " (Default)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the AI configuration for this generation.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleBackgroundPreview}>
{`加入后台队列(运行 ${runningPreviewTaskCount}/3排队 ${queuedPreviewTaskCount}`}
</Button>
<Button type="button" variant="outline" size="sm" onClick={handlePreview} disabled={previewLoading || activePreviewTaskCount > 0}>
{previewLoading ? "Generating..." : "立即预览"}
</Button>
</div>
<FormField
control={control}
name="aiSourceText"
render={({ field }) => (
<FormItem>
<FormLabel>Source Exam Text</FormLabel>
<FormControl>
<Textarea
placeholder="Paste the full exam text to parse into questions."
className="min-h-[200px]"
{...field}
/>
</FormControl>
<FormDescription>
AI will extract questions and structure from this text.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{previewTasks.length > 0 ? (
<div className="rounded-md border p-3 space-y-2">
<div className="text-sm font-medium"></div>
<div className="space-y-2">
{previewTasks.slice(0, 6).map((task) => (
<div key={task.id} className="rounded-md border p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium truncate">{task.title}</div>
<div className="text-xs text-muted-foreground">{formatTaskTime(task.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{task.status === "queued"
? "排队中"
: task.status === "running"
? "生成中"
: task.status === "success"
? "已完成"
: `失败:${task.message || "生成失败"}`}
</div>
{task.status === "success" && task.result ? (
<div className="mt-2 flex justify-end">
<Button type="button" variant="ghost" size="sm" onClick={() => handleOpenPreviewTask(task.id)}>
</Button>
</div>
) : null}
</div>
))}
</div>
</div>
) : null}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,190 @@
"use client"
import type { Control } from "react-hook-form"
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import type { ExamFormValues } from "./exam-form-types"
type ExamBasicInfoFormProps = {
control: Control<ExamFormValues>
subjects: { id: string; name: string }[]
grades: { id: string; name: string }[]
loadingSubjects: boolean
loadingGrades: boolean
}
export function ExamBasicInfoForm({
control,
subjects,
grades,
loadingSubjects,
loadingGrades,
}: ExamBasicInfoFormProps) {
return (
<Card>
<CardHeader>
<CardTitle>Exam Details</CardTitle>
<CardDescription>
Define the core information for your exam.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{subjects.map((subject) => (
<SelectItem key={subject.id} value={subject.id}>
{subject.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="grade"
render={({ field }) => (
<FormItem>
<FormLabel>Grade Level</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{grades.map((grade) => (
<SelectItem key={grade.id} value={grade.id}>
{grade.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField
control={control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1">Level 1 (Easy)</SelectItem>
<SelectItem value="2">Level 2</SelectItem>
<SelectItem value="3">Level 3 (Medium)</SelectItem>
<SelectItem value="4">Level 4</SelectItem>
<SelectItem value="5">Level 5 (Hard)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="totalScore"
render={({ field }) => (
<FormItem>
<FormLabel>Total Score</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="durationMin"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (min)</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="scheduledAt"
render={({ field }) => (
<FormItem>
<FormLabel>Schedule Start Time (Optional)</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormDescription>
If set, this exam will be scheduled for a specific time.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest"
import { formSchema } from "./exam-form-types"
describe("formSchema", () => {
it("should validate manual mode with required fields", () => {
const result = formSchema.safeParse({
mode: "manual",
title: "Test Exam",
subject: "math",
grade: "g1",
difficulty: "3",
totalScore: 100,
durationMin: 90,
})
expect(result.success).toBe(true)
})
it("should reject manual mode without title", () => {
const result = formSchema.safeParse({
mode: "manual",
title: "",
subject: "math",
grade: "g1",
difficulty: "3",
totalScore: 100,
durationMin: 90,
})
expect(result.success).toBe(false)
})
it("should validate AI mode with source text", () => {
const result = formSchema.safeParse({
mode: "ai",
aiSourceText: "Some exam text content here",
})
expect(result.success).toBe(true)
})
it("should reject AI mode without source text", () => {
const result = formSchema.safeParse({
mode: "ai",
aiSourceText: "",
})
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,133 @@
import * as z from "zod"
import type { Question } from "@/modules/questions/types"
import type { AiProviderSummary } from "@/modules/settings/actions"
import type { ExamNode } from "./assembly/selected-question-list"
export const formSchema = z.object({
title: z.string().optional(),
subject: z.string().optional(),
grade: z.string().optional(),
difficulty: z.string().optional(),
totalScore: z.coerce.number().min(1, "Total score must be at least 1.").optional(),
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes.").optional(),
scheduledAt: z.string().optional(),
mode: z.enum(["manual", "ai"]),
aiSourceText: z.string().optional(),
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
aiProviderId: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.mode === "ai") {
if (!data.aiSourceText?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["aiSourceText"],
message: "Source exam text is required for AI generation.",
})
}
return
}
if (!data.title?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["title"],
message: "Title must be at least 2 characters.",
})
}
if (!data.subject?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["subject"],
message: "Subject is required.",
})
}
if (!data.grade?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["grade"],
message: "Grade is required.",
})
}
if (!data.difficulty?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["difficulty"],
message: "Difficulty is required.",
})
}
if (typeof data.totalScore !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["totalScore"],
message: "Total score must be at least 1.",
})
}
if (typeof data.durationMin !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["durationMin"],
message: "Duration must be at least 10 minutes.",
})
}
})
export type ExamFormValues = z.infer<typeof formSchema>
export type PreviewQuestion = {
id: string
type: Question["type"]
difficulty: number
score: number
content: Question["content"]
}
export type EditableQuestionContent = {
text: string
options: Array<{ id: string; text: string; isCorrect: boolean }>
subQuestions: Array<{ id: string; text: string; answer?: string; score?: number }>
}
export type PreviewSnapshotMeta = {
subject: string
grade: string
durationMin: number
totalScore: number
}
export type PreviewBackgroundTask = {
id: string
createdAt: number
status: "queued" | "running" | "success" | "failed"
title: string
signature: string
message?: string
result?: {
title: string
nodes: ExamNode[]
rawOutput: string
meta: PreviewSnapshotMeta
formValues: Pick<ExamFormValues, "title" | "subject" | "grade" | "difficulty" | "totalScore" | "durationMin" | "aiSourceText" | "aiQuestionCount" | "aiProviderId">
}
}
export const aiProviderLabels: Record<AiProviderSummary["provider"], string> = {
zhipu: "智谱",
openai: "OpenAI",
gemini: "Gemini",
custom: "Custom",
}
export const defaultValues: Partial<ExamFormValues> = {
title: "",
subject: "",
grade: "",
difficulty: "3",
totalScore: 100,
durationMin: 90,
mode: "manual",
scheduledAt: "",
aiSourceText: "",
aiQuestionCount: undefined,
aiProviderId: "",
}
export const previewTaskStorageKey = "exam-preview-background-tasks:v1"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
"use client"
import { Loader2, Sparkles, BookOpen } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
type ExamModeSelectorProps = {
mode: "manual" | "ai"
setMode: (mode: "manual" | "ai") => void
isPending: boolean
handleCreateClick: () => void
}
export function ExamModeSelector({
mode,
setMode,
isPending,
handleCreateClick,
}: ExamModeSelectorProps) {
return (
<Card>
<CardHeader>
<CardTitle>Assembly Mode</CardTitle>
<CardDescription>
Choose how to build the exam structure.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col space-y-3">
<button
type="button"
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
mode === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
)}
onClick={() => setMode("manual")}
>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">Manual Assembly</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Manually select questions from the bank and organize structure.
</span>
</button>
<button
type="button"
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
mode === "ai" ? "border-primary ring-1 ring-primary" : "border-border"
)}
onClick={() => setMode("ai")}
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<span className="font-medium">AI Generation</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Automatically generate a draft exam based on your input.
</span>
</button>
</div>
</CardContent>
<CardFooter>
<Button type="button" className="w-full" disabled={isPending} onClick={handleCreateClick}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isPending
? "Creating Draft..."
: mode === "ai"
? "后台生成试卷"
: "Create & Start Building"}
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,185 @@
"use client"
import type { ReactNode } from "react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/shared/components/ui/dialog"
import type { ExamNode } from "./assembly/selected-question-list"
import type { EditableQuestionContent, PreviewSnapshotMeta } from "./exam-form-types"
import { ExamPreviewQuestionEditor } from "./exam-preview-question-editor"
type ExamPreviewDialogProps = {
previewOpen: boolean
setPreviewOpen: (open: boolean) => void
previewLoading: boolean
previewNodes: ExamNode[]
previewTitle: string
previewRawOutput: string
previewMeta: PreviewSnapshotMeta | null
selectedQuestionId: string
setSelectedQuestionId: (id: string) => void
rewriteInstruction: string
setRewriteInstruction: (value: string) => void
rewritingQuestion: boolean
previewQuestionRows: Array<{ node: ExamNode; sectionTitle?: string }>
selectedPreviewQuestion: ExamNode | null
selectedPreviewContent: EditableQuestionContent | null
activePreviewMeta: PreviewSnapshotMeta
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
parseEditableContent: (raw: unknown) => EditableQuestionContent
handleRewriteSelectedQuestion: () => void
handleConfirmCreate: () => void
previewTitleValue?: string
}
export function ExamPreviewDialog({
previewOpen,
setPreviewOpen,
previewLoading,
previewNodes,
previewTitle,
previewRawOutput,
selectedQuestionId,
setSelectedQuestionId,
rewriteInstruction,
setRewriteInstruction,
rewritingQuestion,
previewQuestionRows,
selectedPreviewQuestion,
selectedPreviewContent,
activePreviewMeta,
updatePreviewQuestionNode,
parseEditableContent,
handleRewriteSelectedQuestion,
handleConfirmCreate,
previewTitleValue,
}: ExamPreviewDialogProps) {
const renderSelectablePreview = (nodes: ExamNode[]) => {
let questionCounter = 0
const renderNode = (node: ExamNode, depth: number = 0): ReactNode => {
if (node.type === "group") {
return (
<div key={node.id} className="space-y-3 mb-6">
<h3 className={cn("font-semibold text-foreground/90", depth === 0 ? "text-base" : "text-sm")}>
{node.title || "Section"}
</h3>
<div className="space-y-3">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
if (node.type === "question" && node.question && node.questionId) {
questionCounter += 1
const content = parseEditableContent(node.question.content)
const active = node.questionId === selectedQuestionId
return (
<button
key={node.id}
type="button"
onClick={() => setSelectedQuestionId(node.questionId ?? "")}
className={cn(
"w-full rounded-md border p-3 text-left transition-colors",
active ? "border-primary bg-primary/5" : "border-border hover:bg-accent"
)}
>
<div className="flex gap-2">
<span className="font-semibold text-foreground min-w-[28px]">{questionCounter}.</span>
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text || "未命名题目"}
<span className="text-muted-foreground text-xs ml-2">({node.score ?? 0})</span>
</div>
{content.options.length > 0 ? (
<div className="space-y-1.5">
{content.options.map((opt) => (
<div key={`${node.id}-${opt.id}`} className="text-sm text-foreground/80 flex gap-2">
<span className="min-w-[28px]">{opt.id}.</span>
<span className="whitespace-pre-wrap">{opt.text}</span>
</div>
))}
</div>
) : null}
{content.subQuestions.length > 0 ? (
<div className="space-y-1.5 rounded-md bg-muted/40 p-2">
{content.subQuestions.map((item, index) => (
<div key={`${node.id}-sub-${index}`} className="text-sm text-foreground/80 flex gap-2">
<span className="min-w-[28px]">{item.id}.</span>
<span className="whitespace-pre-wrap">{item.text || "未命名子题"}</span>
{item.score ? <span className="text-xs text-muted-foreground">({item.score})</span> : null}
</div>
))}
</div>
) : null}
</div>
</div>
</button>
)
}
return null
}
return (
<div className="space-y-2">
{nodes.map((node) => renderNode(node))}
</div>
)
}
return (
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-7xl h-[90vh] flex flex-col p-0 gap-0">
<div className="p-4 border-b shrink-0 flex items-center justify-between">
<DialogTitle className="text-lg font-semibold tracking-tight">
{previewTitle || previewTitleValue || "Exam Preview"}
</DialogTitle>
</div>
{previewLoading ? (
<div className="flex-1 py-20 text-center text-muted-foreground">Generating preview...</div>
) : previewNodes.length > 0 ? (
<div className="flex-1 grid grid-cols-12 min-h-0">
<div className="col-span-5 border-r min-h-0 flex flex-col">
<div className="p-4 border-b">
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground mt-1">
{previewQuestionRows.length} · {activePreviewMeta.subject} · {activePreviewMeta.grade} · {activePreviewMeta.durationMin} · {activePreviewMeta.totalScore}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-4">
{renderSelectablePreview(previewNodes)}
</div>
</ScrollArea>
</div>
<div className="col-span-7 min-h-0 flex flex-col">
<ExamPreviewQuestionEditor
selectedQuestion={selectedPreviewQuestion}
selectedContent={selectedPreviewContent}
selectedQuestionId={selectedQuestionId}
updatePreviewQuestionNode={updatePreviewQuestionNode}
parseEditableContent={parseEditableContent}
rewriteInstruction={rewriteInstruction}
setRewriteInstruction={setRewriteInstruction}
rewritingQuestion={rewritingQuestion}
handleRewriteSelectedQuestion={handleRewriteSelectedQuestion}
previewRawOutput={previewRawOutput}
/>
</div>
</div>
) : (
<div className="flex-1 py-20 text-center text-muted-foreground">No preview available</div>
)}
<div className="border-t p-4 flex justify-end">
<Button type="button" disabled={previewLoading || previewNodes.length === 0} onClick={handleConfirmCreate}>
Confirm & Create
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,173 @@
"use client"
import { Loader2, Wand2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Textarea } from "@/shared/components/ui/textarea"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import type { ExamNode } from "./assembly/selected-question-list"
import type { Question } from "@/modules/questions/types"
import type { EditableQuestionContent } from "./exam-form-types"
import { QuestionOptionsEditor } from "./question-options-editor"
import { QuestionSubQuestionsEditor } from "./question-sub-questions-editor"
type ExamPreviewQuestionEditorProps = {
selectedQuestion: ExamNode | null
selectedContent: EditableQuestionContent | null
selectedQuestionId: string
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
parseEditableContent: (raw: unknown) => EditableQuestionContent
rewriteInstruction: string
setRewriteInstruction: (value: string) => void
rewritingQuestion: boolean
handleRewriteSelectedQuestion: () => void
previewRawOutput: string
}
export function ExamPreviewQuestionEditor({
selectedQuestion,
selectedContent,
selectedQuestionId,
updatePreviewQuestionNode,
parseEditableContent,
rewriteInstruction,
setRewriteInstruction,
rewritingQuestion,
handleRewriteSelectedQuestion,
previewRawOutput,
}: ExamPreviewQuestionEditorProps) {
if (!selectedQuestion?.question || !selectedContent) {
return <div className="flex-1 py-20 text-center text-muted-foreground"></div>
}
const isChoiceQuestion = selectedQuestion.question.type === "single_choice" || selectedQuestion.question.type === "multiple_choice"
return (
<div className="flex-1 min-h-0 flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground mt-1"> AI </div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label></Label>
<Select
value={selectedQuestion.question.type}
onValueChange={(value) => {
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
return { ...node, question: { ...node.question, type: value as Question["type"] } }
})
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single_choice">single_choice</SelectItem>
<SelectItem value="multiple_choice">multiple_choice</SelectItem>
<SelectItem value="judgment">judgment</SelectItem>
<SelectItem value="text">text</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="number"
min={1}
max={5}
value={selectedQuestion.question.difficulty ?? 3}
onChange={(event) => {
const next = Number.parseInt(event.target.value || "3", 10)
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
return { ...node, question: { ...node.question, difficulty: Number.isFinite(next) ? next : 3 } }
})
}}
/>
</div>
<div className="space-y-1">
<Label></Label>
<Input
type="number"
min={0}
value={selectedQuestion.score ?? 0}
onChange={(event) => {
const next = Number.parseInt(event.target.value || "0", 10)
updatePreviewQuestionNode(selectedQuestionId, (node) => ({ ...node, score: Number.isFinite(next) ? next : 0 }))
}}
/>
</div>
</div>
<div className="space-y-1">
<Label></Label>
<Textarea
className="min-h-[140px]"
value={selectedContent.text}
onChange={(event) => {
const text = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
return { ...node, question: { ...node.question, content: { ...current, text } } }
})
}}
/>
</div>
{isChoiceQuestion ? (
<QuestionOptionsEditor
selectedQuestionId={selectedQuestionId}
selectedContent={selectedContent}
questionType={selectedQuestion.question.type}
updatePreviewQuestionNode={updatePreviewQuestionNode}
parseEditableContent={parseEditableContent}
/>
) : null}
<QuestionSubQuestionsEditor
selectedQuestionId={selectedQuestionId}
selectedContent={selectedContent}
updatePreviewQuestionNode={updatePreviewQuestionNode}
parseEditableContent={parseEditableContent}
/>
<div className="rounded-md border p-3 space-y-2">
<Label>AI </Label>
<Textarea
className="min-h-[90px]"
placeholder="例如:把这题改成更难、增加一个干扰项、保持总分不变。"
value={rewriteInstruction}
onChange={(event) => setRewriteInstruction(event.target.value)}
/>
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={handleRewriteSelectedQuestion} disabled={rewritingQuestion}>
{rewritingQuestion ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wand2 className="mr-2 h-4 w-4" />}
AI
</Button>
</div>
</div>
{previewRawOutput ? (
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs font-medium mb-2"></div>
<pre className="whitespace-pre-wrap text-xs text-muted-foreground">{previewRawOutput}</pre>
</div>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { createId } from "@paralleldrive/cuid2"
import type { AiPreviewData } from "../actions"
import type { ExamNode } from "./assembly/selected-question-list"
import type { Question } from "@/modules/questions/types"
import type { EditableQuestionContent, ExamFormValues, PreviewSnapshotMeta } from "./exam-form-types"
export function buildPreviewNodes(data: AiPreviewData): ExamNode[] {
const now = new Date()
const toQuestionNode = (q: { id: string; type: Question["type"]; difficulty: number; score: number; content: Question["content"] }): ExamNode => ({
id: q.id,
type: "question",
questionId: q.id,
score: q.score,
question: {
id: q.id,
content: q.content,
type: q.type,
difficulty: q.difficulty,
createdAt: now,
updatedAt: now,
author: null,
knowledgePoints: [],
} satisfies Question,
})
if (data.sections && data.sections.length > 0) {
return data.sections.map((section) => ({
id: section.id || createId(),
type: "group",
title: section.title,
children: section.questions.map((q) => toQuestionNode(q)),
}))
}
return (data.questions ?? []).map((q) => toQuestionNode(q))
}
export function parseEditableContent(raw: unknown): EditableQuestionContent {
const parseFromObject = (value: unknown): EditableQuestionContent => {
if (!value || typeof value !== "object") return { text: "", options: [], subQuestions: [] }
const record = value as { text?: unknown; options?: unknown; subQuestions?: unknown }
const text = typeof record.text === "string" ? record.text : ""
const options = Array.isArray(record.options)
? record.options.map((opt, index) => {
const item = opt && typeof opt === "object" ? opt as { id?: unknown; text?: unknown; isCorrect?: unknown } : {}
return {
id: typeof item.id === "string" && item.id.trim().length > 0 ? item.id : String.fromCharCode(65 + index),
text: typeof item.text === "string" ? item.text : "",
isCorrect: typeof item.isCorrect === "boolean" ? item.isCorrect : false,
}
})
: []
const subQuestions = Array.isArray(record.subQuestions)
? record.subQuestions.map((item, index) => {
const row = item && typeof item === "object"
? item as { id?: unknown; text?: unknown; answer?: unknown; score?: unknown }
: {}
const rawScore = typeof row.score === "number" ? row.score : Number.parseInt(String(row.score ?? ""), 10)
return {
id: typeof row.id === "string" && row.id.trim().length > 0 ? row.id : String(index + 1),
text: typeof row.text === "string" ? row.text : "",
answer: typeof row.answer === "string" ? row.answer : "",
score: Number.isFinite(rawScore) ? rawScore : undefined,
}
})
: []
return { text, options, subQuestions }
}
if (typeof raw === "string") {
try {
return parseFromObject(JSON.parse(raw))
} catch {
return { text: raw, options: [], subQuestions: [] }
}
}
return parseFromObject(raw)
}
export function flattenPreviewQuestions(nodes: ExamNode[]) {
const rows: Array<{ node: ExamNode; sectionTitle?: string }> = []
const walk = (items: ExamNode[], sectionTitle?: string) => {
items.forEach((node) => {
if (node.type === "question" && node.questionId && node.question) {
rows.push({ node, sectionTitle })
return
}
if (node.type === "group" && node.children) {
walk(node.children, node.title || sectionTitle)
}
})
}
walk(nodes)
return rows
}
export function findPreviewQuestionNode(nodes: ExamNode[], questionId: string): ExamNode | null {
for (const node of nodes) {
if (node.type === "question" && node.questionId === questionId && node.question) {
return node
}
if (node.type === "group" && node.children) {
const found = findPreviewQuestionNode(node.children, questionId)
if (found) return found
}
}
return null
}
export function updatePreviewQuestionNodeInList(questionId: string, items: ExamNode[], updater: (node: ExamNode) => ExamNode): ExamNode[] {
return items.map((node) => {
if (node.type === "question" && node.questionId === questionId && node.question) {
return updater(node)
}
if (node.type === "group" && node.children) {
return { ...node, children: updatePreviewQuestionNodeInList(questionId, node.children, updater) }
}
return node
})
}
export function buildPreviewSignature(values: ExamFormValues) {
return JSON.stringify({
sourceText: values.aiSourceText?.trim() || "",
questionCount: values.aiQuestionCount ?? null,
providerId: values.aiProviderId ?? "",
title: values.title?.trim() || "",
subject: values.subject?.trim() || "",
grade: values.grade?.trim() || "",
difficulty: values.difficulty ?? "",
totalScore: values.totalScore ?? "",
durationMin: values.durationMin ?? "",
})
}
export function buildPreviewPayload(nodes: ExamNode[]) {
const questions: Array<{
id: string
type: Question["type"]
difficulty: number
score: number
content: Question["content"]
}> = []
const seen = new Set<string>()
const collect = (items: ExamNode[]) => {
items.forEach((node) => {
if (node.type === "question" && node.questionId && node.question) {
if (!seen.has(node.questionId)) {
seen.add(node.questionId)
questions.push({
id: node.questionId,
type: node.question.type,
difficulty: node.question.difficulty ?? 3,
score: node.score ?? 0,
content: node.question.content,
})
}
return
}
if (node.type === "group" && node.children) collect(node.children)
})
}
collect(nodes)
const cleanStructure = (items: ExamNode[]): Array<Omit<ExamNode, "question"> & { children?: unknown[] }> => {
return items.map((node) => {
const { question, ...rest } = node
void question
if (node.type === "group") {
return { ...rest, children: cleanStructure(node.children || []) }
}
return rest
})
}
return {
questions,
structure: cleanStructure(nodes),
}
}
export function buildPreviewRequestData(values: ExamFormValues) {
const sourceText = values.aiSourceText?.trim()
if (!sourceText) return null
const formData = new FormData()
if (values.title?.trim()) formData.append("title", values.title.trim())
if (values.subject?.trim()) formData.append("subject", values.subject.trim())
if (values.grade?.trim()) formData.append("grade", values.grade.trim())
const previewDifficulty = Number.parseInt(String(values.difficulty ?? "3"), 10)
const previewTotalScore = typeof values.totalScore === "number" && values.totalScore > 0 ? values.totalScore : 100
const previewDurationMin = typeof values.durationMin === "number" && values.durationMin > 0 ? values.durationMin : 90
formData.append("difficulty", Number.isFinite(previewDifficulty) && previewDifficulty >= 1 && previewDifficulty <= 5 ? String(previewDifficulty) : "3")
formData.append("totalScore", String(previewTotalScore))
formData.append("durationMin", String(previewDurationMin))
formData.append("aiSourceText", sourceText)
if (values.aiQuestionCount) formData.append("aiQuestionCount", values.aiQuestionCount.toString())
if (values.aiProviderId) formData.append("aiProviderId", values.aiProviderId)
const meta: PreviewSnapshotMeta = {
subject: values.subject?.trim() || "—",
grade: values.grade?.trim() || "—",
durationMin: previewDurationMin,
totalScore: previewTotalScore,
}
return { formData, meta, signature: buildPreviewSignature(values) }
}

View File

@@ -0,0 +1,130 @@
"use client"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import type { ExamNode } from "./assembly/selected-question-list"
import type { EditableQuestionContent } from "./exam-form-types"
type QuestionOptionsEditorProps = {
selectedQuestionId: string
selectedContent: EditableQuestionContent
questionType: string
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
parseEditableContent: (raw: unknown) => EditableQuestionContent
}
export function QuestionOptionsEditor({
selectedQuestionId,
selectedContent,
questionType,
updatePreviewQuestionNode,
parseEditableContent,
}: QuestionOptionsEditorProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const nextId = String.fromCharCode(65 + current.options.length)
return {
...node,
question: {
...node.question,
content: {
...current,
options: [...current.options, { id: nextId, text: "", isCorrect: false }],
},
},
}
})
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
{selectedContent.options.map((option, optionIndex) => (
<div key={`${option.id}-${optionIndex}`} className="flex items-center gap-2 rounded-md border p-2">
<Input
className="w-16"
value={option.id}
onChange={(event) => {
const nextId = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const options = current.options.map((item, idx) => idx === optionIndex ? { ...item, id: nextId } : item)
return { ...node, question: { ...node.question, content: { ...current, options } } }
})
}}
/>
<Input
className="flex-1"
value={option.text}
onChange={(event) => {
const text = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const options = current.options.map((item, idx) => idx === optionIndex ? { ...item, text } : item)
return { ...node, question: { ...node.question, content: { ...current, options } } }
})
}}
/>
<div className="flex items-center gap-2 px-2">
<Checkbox
aria-label={`标记选项 ${option.id} 为正确答案`}
checked={option.isCorrect}
onCheckedChange={(checked) => {
const isCorrect = Boolean(checked)
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const options = current.options.map((item, idx) => {
if (idx !== optionIndex) {
if (questionType === "single_choice") {
return { ...item, isCorrect: false }
}
return item
}
return { ...item, isCorrect }
})
return { ...node, question: { ...node.question, content: { ...current, options } } }
})
}}
/>
<span className="text-xs text-muted-foreground"></span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="删除选项"
onClick={() => {
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const options = current.options.filter((_, idx) => idx !== optionIndex)
return { ...node, question: { ...node.question, content: { ...current, options } } }
})
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import type { ExamNode } from "./assembly/selected-question-list"
import type { EditableQuestionContent } from "./exam-form-types"
type QuestionSubQuestionsEditorProps = {
selectedQuestionId: string
selectedContent: EditableQuestionContent
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
parseEditableContent: (raw: unknown) => EditableQuestionContent
}
export function QuestionSubQuestionsEditor({
selectedQuestionId,
selectedContent,
updatePreviewQuestionNode,
parseEditableContent,
}: QuestionSubQuestionsEditorProps) {
return (
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<Label></Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
return {
...node,
question: {
...node.question,
content: {
...current,
subQuestions: [
...current.subQuestions,
{ id: String(current.subQuestions.length + 1), text: "", answer: "", score: undefined },
],
},
},
}
})
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{selectedContent.subQuestions.length > 0 ? (
<div className="space-y-2">
{selectedContent.subQuestions.map((item, subIndex) => (
<div key={`${item.id}-${subIndex}`} className="grid grid-cols-[64px_1fr_1fr_84px_36px] items-center gap-2 rounded-md border p-2">
<Input
value={item.id}
onChange={(event) => {
const nextId = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex ? { ...row, id: nextId } : row)
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
})
}}
/>
<Input
value={item.text}
placeholder="子题内容"
onChange={(event) => {
const text = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex ? { ...row, text } : row)
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
})
}}
/>
<Input
value={item.answer ?? ""}
placeholder="参考答案"
onChange={(event) => {
const answer = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex ? { ...row, answer } : row)
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
})
}}
/>
<Input
type="number"
min={0}
value={typeof item.score === "number" ? item.score : ""}
placeholder="分值"
onChange={(event) => {
const next = Number.parseInt(event.target.value, 10)
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex
? { ...row, score: Number.isFinite(next) ? next : undefined }
: row)
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
})
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node
const current = parseEditableContent(node.question.content)
const subQuestions = current.subQuestions.filter((_, idx) => idx !== subIndex)
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
})
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
) : (
<div className="text-xs text-muted-foreground"></div>
)}
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { db } from "@/shared/db"
import { exams, examQuestions, questions, subjects, grades } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
import { eq, desc, like, and, or, inArray } from "drizzle-orm"
import { cache } from "react"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
import type { DataScope } from "@/shared/types/permissions"
export type GetExamsParams = {
q?: string
@@ -49,7 +50,7 @@ const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
}
export const getExams = cache(async (params: GetExamsParams) => {
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
const conditions = []
if (params.q) {
@@ -61,7 +62,28 @@ export const getExams = cache(async (params: GetExamsParams) => {
conditions.push(eq(exams.status, params.status))
}
// Note: Difficulty is stored in JSON description field in current schema,
// Data scope filtering
if (params.scope.type === "owned") {
conditions.push(eq(exams.creatorId, params.scope.userId))
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Teacher can see exams for grades their classes belong to
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, params.scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
}
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
}
// "all" type: no filtering
// "class_members": student sees published exams for their grade (would need student's gradeId)
// Note: Difficulty is stored in JSON description field in current schema,
// so we might need to filter in memory or adjust schema.
// For now, let's fetch and filter in memory if difficulty is needed,
// or just ignore strict DB filtering for JSON fields to keep it simple.
@@ -104,7 +126,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
return result
})
export const getExamById = cache(async (id: string) => {
export const getExamById = cache(async (id: string, scope?: DataScope) => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, id),
with: {
@@ -121,6 +143,26 @@ export const getExamById = cache(async (id: string) => {
if (!exam) return null
// Data scope verification for single-item fetch
if (scope && scope.type !== "all") {
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
return null
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) {
return null
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
return null
}
}
}
const meta = parseExamMeta(exam.description || null)
return {

View File

@@ -0,0 +1,295 @@
"use client"
import { useEffect, useRef, useState } from "react"
import type { UseFormReturn } from "react-hook-form"
import { toast } from "sonner"
import PQueue from "p-queue"
import { createId } from "@paralleldrive/cuid2"
import { previewAiExamAction, regenerateAiQuestionAction, type AiPreviewData, type AiRewriteQuestionData } from "../actions"
import type { ExamNode } from "../components/assembly/selected-question-list"
import {
type ExamFormValues,
type PreviewSnapshotMeta,
type PreviewBackgroundTask,
previewTaskStorageKey,
} from "../components/exam-form-types"
import {
buildPreviewNodes,
parseEditableContent,
flattenPreviewQuestions,
findPreviewQuestionNode,
updatePreviewQuestionNodeInList,
buildPreviewSignature,
buildPreviewPayload,
buildPreviewRequestData,
} from "../components/exam-preview-utils"
export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
const [previewOpen, setPreviewOpen] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewNodes, setPreviewNodes] = useState<ExamNode[]>([])
const [previewTitle, setPreviewTitle] = useState("")
const [previewRawOutput, setPreviewRawOutput] = useState("")
const [previewSignature, setPreviewSignature] = useState("")
const [previewMeta, setPreviewMeta] = useState<PreviewSnapshotMeta | null>(null)
const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([])
const [selectedQuestionId, setSelectedQuestionId] = useState<string>("")
const [rewriteInstruction, setRewriteInstruction] = useState("")
const [rewritingQuestion, setRewritingQuestion] = useState(false)
const previewQueueRef = useRef<PQueue | null>(null)
if (!previewQueueRef.current) {
previewQueueRef.current = new PQueue({ concurrency: 3 })
}
const previewQueue = previewQueueRef.current
const persistPreviewTasks = (tasks: PreviewBackgroundTask[]) => {
try {
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
} catch (error) {
console.error(error)
}
}
useEffect(() => {
try {
const raw = window.localStorage.getItem(previewTaskStorageKey)
if (!raw) return
const parsed = JSON.parse(raw) as PreviewBackgroundTask[]
if (!Array.isArray(parsed)) return
const restoredTasks = parsed
.filter((task) => task && typeof task.id === "string")
.map((task) => {
if (task.status === "queued" || task.status === "running") {
return {
...task,
status: "failed" as const,
message: "页面刷新后任务已中断,请重新生成",
}
}
return task
})
setPreviewTasks(restoredTasks)
if (restoredTasks.length > 0) {
form.setValue("mode", "ai")
}
} catch (error) {
console.error(error)
setPreviewTasks([])
}
}, [form])
useEffect(() => {
return () => {
previewQueue.clear()
}
}, [previewQueue])
useEffect(() => {
persistPreviewTasks(previewTasks)
}, [previewTasks])
const updatePreviewQuestionNode = (questionId: string, updater: (node: ExamNode) => ExamNode) => {
setPreviewNodes((prev) => updatePreviewQuestionNodeInList(questionId, prev, updater))
}
const updateSelectedQuestionFromAi = (questionId: string, data: AiRewriteQuestionData) => {
updatePreviewQuestionNode(questionId, (node) => {
if (!node.question) return node
return {
...node,
score: data.score,
question: {
...node.question,
type: data.type,
difficulty: data.difficulty,
content: data.content,
updatedAt: new Date(),
},
}
})
}
const applyPreviewResult = (input: { data: AiPreviewData; signature: string; meta: PreviewSnapshotMeta }) => {
setPreviewTitle(input.data.title)
const nextNodes = buildPreviewNodes(input.data)
setPreviewNodes(nextNodes)
const firstQuestion = flattenPreviewQuestions(nextNodes)[0]
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
setPreviewRawOutput(input.data.rawOutput ?? "")
setPreviewSignature(input.signature)
setPreviewMeta(input.meta)
setRewriteInstruction("")
setPreviewOpen(true)
}
const handlePreview = async () => {
const values = form.getValues()
const requestData = buildPreviewRequestData(values)
if (!requestData) {
toast.error("Please paste the full exam text first")
return
}
setPreviewOpen(false)
setPreviewLoading(true)
setPreviewNodes([])
setPreviewRawOutput("")
setPreviewSignature("")
setSelectedQuestionId("")
setRewriteInstruction("")
try {
const result = await previewAiExamAction(null, requestData.formData)
if (result.success && result.data) {
applyPreviewResult({
data: result.data,
signature: requestData.signature,
meta: requestData.meta,
})
} else {
toast.error(result.message || "Failed to generate preview")
}
} catch {
toast.error("Failed to generate preview")
} finally {
setPreviewLoading(false)
}
}
const handleBackgroundPreview = () => {
const values = form.getValues()
const requestData = buildPreviewRequestData(values)
if (!requestData) {
toast.error("Please paste the full exam text first")
return
}
const taskId = createId()
const taskTitle = values.title?.trim() || "未命名试卷"
setPreviewTasks((prev) => {
const next = [{ id: taskId, createdAt: Date.now(), status: "queued" as const, title: taskTitle, signature: requestData.signature }, ...prev]
persistPreviewTasks(next)
return next
})
toast.success("已加入后台队列,可继续编辑页面")
void previewQueue.add(async () => {
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "running" }
: task))
try {
const result = await previewAiExamAction(null, requestData.formData)
const data = result.data
if (result.success && data) {
const nextNodes = buildPreviewNodes(data)
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? {
...task,
status: "success",
result: {
title: data.title,
nodes: nextNodes,
rawOutput: data.rawOutput ?? "",
meta: requestData.meta,
formValues: { title: values.title, subject: values.subject, grade: values.grade, difficulty: values.difficulty, totalScore: values.totalScore, durationMin: values.durationMin, aiSourceText: values.aiSourceText, aiQuestionCount: values.aiQuestionCount, aiProviderId: values.aiProviderId },
},
}
: task))
toast.success(`后台生成完成:${taskTitle}`)
return
}
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: result.message || "Failed to generate preview" }
: task))
toast.error(`后台生成失败:${taskTitle}`)
} catch {
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: "Failed to generate preview" }
: task))
toast.error(`后台生成失败:${taskTitle}`)
}
})
}
const handleOpenPreviewTask = (taskId: string) => {
const task = previewTasks.find((item) => item.id === taskId)
if (!task || task.status !== "success" || !task.result) return
const tv = task.result.formValues
const fields = ["title", "subject", "grade", "difficulty", "totalScore", "durationMin", "aiSourceText", "aiQuestionCount", "aiProviderId"] as const
fields.forEach((key) => {
if (typeof tv[key] !== "undefined") form.setValue(key, tv[key])
})
setPreviewTitle(task.result.title)
setPreviewNodes(task.result.nodes)
setPreviewRawOutput(task.result.rawOutput)
setPreviewSignature(task.signature)
setPreviewMeta(task.result.meta)
const firstQuestion = flattenPreviewQuestions(task.result.nodes)[0]
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
setRewriteInstruction("")
setPreviewOpen(true)
}
const handleRewriteSelectedQuestion = async () => {
if (!selectedQuestionId) {
toast.error("请先选择一个题目")
return
}
const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId)
if (!selected?.question) {
toast.error("未找到选中的题目")
return
}
const instruction = rewriteInstruction.trim()
if (!instruction) {
toast.error("请输入重写指令")
return
}
setRewritingQuestion(true)
try {
const content = parseEditableContent(selected.question.content)
const questionPayload = {
type: selected.question.type,
difficulty: selected.question.difficulty ?? 3,
score: selected.score ?? 0,
content: {
text: content.text,
options: content.options.map((opt) => ({
id: opt.id, text: opt.text, isCorrect: opt.isCorrect,
})),
subQuestions: content.subQuestions.map((item) => ({
id: item.id, text: item.text, answer: item.answer, score: item.score,
})),
},
}
const formData = new FormData()
formData.append("instruction", instruction)
formData.append("questionJson", JSON.stringify(questionPayload))
const providerId = form.getValues("aiProviderId")
const sourceText = form.getValues("aiSourceText")
if (providerId) formData.append("aiProviderId", providerId)
if (sourceText) formData.append("sourceText", sourceText)
const result = await regenerateAiQuestionAction(null, formData)
if (!result.success || !result.data) {
toast.error(result.message || "AI 重写失败")
return
}
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
setRewriteInstruction("")
toast.success("题目已按指令重写")
} catch {
toast.error("AI 重写失败")
} finally {
setRewritingQuestion(false)
}
}
return {
previewOpen, setPreviewOpen, previewLoading, previewNodes, setPreviewNodes,
previewTitle, previewRawOutput, previewSignature, previewMeta, previewTasks,
selectedQuestionId, setSelectedQuestionId, rewriteInstruction, setRewriteInstruction,
rewritingQuestion, buildPreviewNodes, parseEditableContent, flattenPreviewQuestions,
findPreviewQuestionNode, updatePreviewQuestionNode, buildPreviewPayload,
buildPreviewRequestData, buildPreviewSignature, handlePreview, handleBackgroundPreview,
handleOpenPreviewTask, handleRewriteSelectedQuestion,
}
}

View File

@@ -2,9 +2,10 @@
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { and, count, eq, inArray } from "drizzle-orm"
import { and, count, eq } from "drizzle-orm"
import { auth } from "@/auth"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
@@ -16,51 +17,11 @@ import {
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
type TeacherRole = "admin" | "teacher"
type StudentRole = "student"
const getSessionUserId = async (): Promise<string | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
return userId.length > 0 ? userId : null
}
async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> {
const userId = await getSessionUserId()
if (!userId) throw new Error("Unauthorized")
const [row] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
.limit(1)
if (!row) throw new Error("Unauthorized")
return { id: row.id, role: row.role as TeacherRole }
}
async function ensureStudent(): Promise<{ id: string; role: StudentRole }> {
const userId = await getSessionUserId()
if (!userId) throw new Error("Unauthorized")
const [row] = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "student")))
.limit(1)
if (!row) throw new Error("Unauthorized")
return { id: row.id, role: "student" }
}
const parseStudentIds = (raw: string): string[] => {
return raw
.split(/[,\n\r\t ]+/g)
@@ -73,7 +34,7 @@ export async function createHomeworkAssignmentAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher()
const ctx = await requirePermission(Permissions.HOMEWORK_CREATE)
const targetStudentIdsJson = formData.get("targetStudentIdsJson")
const targetStudentIdsText = formData.get("targetStudentIdsText")
@@ -126,11 +87,11 @@ export async function createHomeworkAssignmentAction(
if (!exam) return { success: false, message: "Exam not found" }
if (user.role !== "admin" && classRow.teacherId !== user.id) {
if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) {
const assignedSubjectRows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id)))
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, ctx.userId)))
if (assignedSubjectRows.length === 0) {
return { success: false, message: "Not assigned to this class" }
}
@@ -150,10 +111,10 @@ export async function createHomeworkAssignmentAction(
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
const classScope =
user.role === "admin"
ctx.dataScope.type === "all"
? eq(classes.id, input.classId)
: classRow.teacherId === user.id
? eq(classes.teacherId, user.id)
: classRow.teacherId === ctx.userId
? eq(classes.teacherId, ctx.userId)
: eq(classes.id, input.classId)
const classStudentIds = (
@@ -185,7 +146,7 @@ export async function createHomeworkAssignmentAction(
description: input.description ?? null,
structure: publish ? (exam.structure as unknown) : null,
status: publish ? "published" : "draft",
creatorId: user.id,
creatorId: ctx.userId,
availableAt,
dueAt,
allowLate: input.allowLate ?? false,
@@ -218,8 +179,11 @@ export async function createHomeworkAssignmentAction(
revalidatePath("/teacher/homework/submissions")
return { success: true, message: "Assignment created", data: assignmentId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
} 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: "Unexpected error" }
}
}
@@ -229,7 +193,7 @@ export async function startHomeworkSubmissionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureStudent()
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
const assignmentId = formData.get("assignmentId")
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
@@ -240,7 +204,7 @@ export async function startHomeworkSubmissionAction(
if (assignment.status !== "published") return { success: false, message: "Assignment not available" }
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)),
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, ctx.userId)),
})
if (!target) return { success: false, message: "Not assigned" }
@@ -249,7 +213,7 @@ export async function startHomeworkSubmissionAction(
const [attemptRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id)))
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, ctx.userId)))
const attemptNo = (attemptRow?.c ?? 0) + 1
if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" }
@@ -258,7 +222,7 @@ export async function startHomeworkSubmissionAction(
await db.insert(homeworkSubmissions).values({
id: submissionId,
assignmentId,
studentId: user.id,
studentId: ctx.userId,
attemptNo,
status: "started",
startedAt: new Date(),
@@ -267,8 +231,11 @@ export async function startHomeworkSubmissionAction(
revalidatePath("/student/learning/assignments")
return { success: true, message: "Started", data: submissionId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
} 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: "Unexpected error" }
}
}
@@ -278,7 +245,7 @@ export async function saveHomeworkAnswerAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureStudent()
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
const submissionId = formData.get("submissionId")
const questionId = formData.get("questionId")
const answerJson = formData.get("answerJson")
@@ -290,7 +257,7 @@ export async function saveHomeworkAnswerAction(
with: { assignment: true },
})
if (!submission) return { success: false, message: "Submission not found" }
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
@@ -316,8 +283,11 @@ export async function saveHomeworkAnswerAction(
})
return { success: true, message: "Saved", data: submissionId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
} 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: "Unexpected error" }
}
}
@@ -327,7 +297,7 @@ export async function submitHomeworkAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureStudent()
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
const submissionId = formData.get("submissionId")
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
@@ -336,7 +306,7 @@ export async function submitHomeworkAction(
with: { assignment: true },
})
if (!submission) return { success: false, message: "Submission not found" }
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
if (submission.status !== "started") return { success: false, message: "Already submitted" }
const now = new Date()
@@ -358,8 +328,11 @@ export async function submitHomeworkAction(
revalidatePath("/student/learning/assignments")
return { success: true, message: "Submitted", data: submissionId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
} 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: "Unexpected error" }
}
}
@@ -369,7 +342,7 @@ export async function gradeHomeworkSubmissionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await ensureTeacher()
await requirePermission(Permissions.HOMEWORK_GRADE)
const rawAnswers = formData.get("answersJson") as string | null
const parsed = GradeHomeworkSchema.safeParse({
@@ -404,8 +377,11 @@ export async function gradeHomeworkSubmissionAction(
revalidatePath("/teacher/homework/submissions")
return { success: true, message: "Grading saved" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
} 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: "Unexpected error" }
}
}

View File

@@ -36,6 +36,7 @@ import type {
StudentRanking,
TeacherGradeTrendItem,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
const recentAssignments = await db.query.homeworkAssignments.findMany({
@@ -122,7 +123,7 @@ const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<s
return map
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string }) => {
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string; scope?: DataScope }) => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
@@ -141,6 +142,37 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
}
// Data scope filtering
if (params?.scope) {
if (params.scope.type === "owned") {
conditions.push(eq(homeworkAssignments.creatorId, params.scope.userId))
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Filter homework by assignments targeting students in teacher's classes
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, params.scope.classIds))
const targetAssignmentIds = db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
// Homework links to exam via sourceExamId, exam has gradeId
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, params.scope.gradeIds))
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
}
// "all" type: no filtering
}
const data = await db.query.homeworkAssignments.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkAssignments.createdAt)],
@@ -168,12 +200,42 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
})
})
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string }) => {
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string; scope?: DataScope }) => {
const creatorId = params.creatorId.trim()
if (!creatorId) return []
const conditions = [eq(homeworkAssignments.creatorId, creatorId)]
// Data scope filtering
if (params.scope) {
if (params.scope.type === "owned") {
// Already filtered by creatorId above
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, params.scope.classIds))
const targetAssignmentIds = db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, params.scope.gradeIds))
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
}
}
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.creatorId, creatorId),
where: and(...conditions),
orderBy: [desc(homeworkAssignments.createdAt)],
with: { sourceExam: true },
})
@@ -239,7 +301,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string }) => {
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string; scope?: DataScope }) => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
if (params?.classId) {
@@ -265,6 +327,39 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
// Data scope filtering
if (params?.scope) {
if (params.scope.type === "owned") {
const creatorAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, params.scope.userId))
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, params.scope.classIds))
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, params.scope.gradeIds))
const gradeAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
conditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
}
const data = await db.query.homeworkSubmissions.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkSubmissions.updatedAt)],
@@ -289,7 +384,7 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
})
})
export const getHomeworkAssignmentById = cache(async (id: string) => {
export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataScope) => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, id),
with: {
@@ -299,6 +394,41 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
if (!assignment) return null
// Data scope verification for single-item fetch
if (scope && scope.type !== "all") {
if (scope.type === "owned" && assignment.creatorId !== scope.userId) {
return null
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
const gradeExamIds = await db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, scope.gradeIds))
const examIds = gradeExamIds.map(e => e.id)
if (!examIds.includes(assignment.sourceExamId)) {
return null
}
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const classStudentIds = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, scope.classIds))
const studentIds = classStudentIds.map(s => s.studentId)
if (studentIds.length > 0) {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(
eq(homeworkAssignmentTargets.assignmentId, id),
inArray(homeworkAssignmentTargets.studentId, studentIds)
),
})
if (!target) return null
} else {
return null
}
}
}
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)

View File

@@ -3,7 +3,6 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession } from "next-auth/react"
import { ChevronRight } from "lucide-react"
import {
@@ -19,6 +18,8 @@ import {
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks"
import { Permissions, type Permission } from "@/shared/types/permissions"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG, Role } from "../config/navigation"
@@ -29,10 +30,31 @@ interface AppSidebarProps {
export function AppSidebar({ mode }: AppSidebarProps) {
const { expanded, toggleSidebar, isMobile } = useSidebar()
const pathname = usePathname()
const { data } = useSession()
const currentRole = (data?.user?.role ?? "teacher") as Role
const { permissions, hasRole } = usePermission()
const navItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
// Determine which role's nav config to use based on permissions
let currentRole: Role = "teacher"
if (permissions.includes(Permissions.SCHOOL_MANAGE)) {
currentRole = "admin"
} else if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
currentRole = "student"
} else if (hasRole("parent")) {
currentRole = "parent"
}
const allNavItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
// Filter nav items by permission
const navItems = allNavItems.filter((item) => {
if (!item.permission) return true
return permissions.includes(item.permission as Permission)
}).map((item) => ({
...item,
items: item.items?.filter((subItem) => {
if (!subItem.permission) return true
return permissions.includes(subItem.permission as Permission)
}),
}))
// Ensure consistent state for hydration
if (!expanded && mode === 'mobile') return null

View File

@@ -15,12 +15,14 @@ import {
Briefcase
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { Permissions } from "@/shared/types/permissions"
export type NavItem = {
title: string
icon: LucideIcon
href: string
items?: { title: string; href: string }[]
permission?: string
items?: { title: string; href: string; permission?: string }[]
}
export type Role = "admin" | "teacher" | "student" | "parent"
@@ -31,11 +33,13 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
title: "Dashboard",
icon: LayoutDashboard,
href: "/admin/dashboard",
permission: Permissions.SCHOOL_MANAGE,
},
{
title: "School Management",
icon: Shield,
href: "/admin/school",
permission: Permissions.SCHOOL_MANAGE,
items: [
{ title: "Schools", href: "/admin/school/schools" },
{ title: "Grades", href: "/admin/school/grades" },
@@ -49,6 +53,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
title: "Users",
icon: Users,
href: "/admin/users",
permission: Permissions.USER_MANAGE,
items: [
{ title: "Teachers", href: "/admin/users/teachers" },
{ title: "Students", href: "/admin/users/students" },
@@ -79,6 +84,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
title: "Settings",
icon: Settings,
href: "/settings",
permission: Permissions.SETTINGS_ADMIN,
},
],
teacher: [
@@ -91,20 +97,23 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
title: "Textbooks",
icon: Library,
href: "/teacher/textbooks",
permission: Permissions.TEXTBOOK_READ,
},
{
title: "Exams",
icon: FileQuestion,
href: "/teacher/exams",
permission: Permissions.EXAM_CREATE,
items: [
{ title: "All Exams", href: "/teacher/exams/all" },
{ title: "Create Exam", href: "/teacher/exams/create" },
{ title: "Create Exam", href: "/teacher/exams/create", permission: Permissions.EXAM_CREATE },
]
},
{
title: "Homework",
icon: PenTool,
href: "/teacher/homework",
permission: Permissions.HOMEWORK_CREATE,
items: [
{ title: "Assignments", href: "/teacher/homework/assignments" },
{ title: "Submissions", href: "/teacher/homework/submissions" },
@@ -114,21 +123,24 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
title: "Question Bank",
icon: ClipboardList,
href: "/teacher/questions",
permission: Permissions.QUESTION_READ,
},
{
title: "Class Management",
icon: Users,
href: "/teacher/classes",
permission: Permissions.CLASS_READ,
items: [
{ title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" },
{ title: "Schedule", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE },
]
},
{
title: "Management",
icon: Briefcase,
href: "/management",
permission: Permissions.GRADE_MANAGE,
items: [
{ title: "Grade Insights", href: "/management/grade/insights" },
]
@@ -144,16 +156,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
title: "My Learning",
icon: BookOpen,
href: "/student/learning",
permission: Permissions.HOMEWORK_SUBMIT,
items: [
{ title: "Courses", href: "/student/learning/courses" },
{ title: "Assignments", href: "/student/learning/assignments" },
{ title: "Textbooks", href: "/student/learning/textbooks" },
{ title: "Assignments", href: "/student/learning/assignments", permission: Permissions.HOMEWORK_SUBMIT },
{ title: "Textbooks", href: "/student/learning/textbooks", permission: Permissions.TEXTBOOK_READ },
]
},
{
title: "Schedule",
icon: Calendar,
href: "/student/schedule",
permission: Permissions.CLASS_SCHEDULE,
},
],
parent: [

View File

@@ -1,52 +1,18 @@
"use server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { db } from "@/shared/db";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } from "@/shared/db/schema";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema";
import { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { createId } from "@paralleldrive/cuid2";
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import { getQuestions, type GetQuestionsParams } from "./data-access";
import type { KnowledgePointOption } from "./types";
import { auth } from "@/auth";
const getSessionUserId = async (): Promise<string | null> => {
const session = await auth();
const userId = String(session?.user?.id ?? "").trim();
return userId.length > 0 ? userId : null;
};
async function ensureTeacher() {
const userId = await getSessionUserId();
if (!userId) {
const [fallback] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(roles.name, ["teacher", "admin"]))
.orderBy(asc(users.createdAt))
.limit(1);
if (!fallback) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return { id: fallback.id, role: fallback.role as "teacher" | "admin" };
}
const [row] = await db
.select({ id: users.id, role: roles.name })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
.limit(1);
if (!row) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return { id: row.id, role: row.role as "teacher" | "admin" };
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
@@ -90,10 +56,10 @@ export async function createNestedQuestion(
formData: FormData | CreateQuestionInput
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const ctx = await requirePermission(Permissions.QUESTION_CREATE);
let rawInput: unknown = formData;
if (formData instanceof FormData) {
const jsonString = formData.get("json");
if (typeof jsonString === "string") {
@@ -116,7 +82,7 @@ export async function createNestedQuestion(
const input = validatedFields.data;
await db.transaction(async (tx) => {
await insertQuestionWithRelations(tx, input, user.id, null);
await insertQuestionWithRelations(tx, input, ctx.userId, null);
});
revalidatePath("/teacher/questions");
@@ -126,11 +92,14 @@ export async function createNestedQuestion(
message: "Question created successfully",
};
} catch (error) {
if (error instanceof Error) {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
if (e instanceof Error) {
return {
success: false,
message: error.message || "Database error occurred",
message: e.message || "Database error occurred",
};
}
@@ -154,8 +123,8 @@ export async function updateQuestionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const canEditAll = user.role === "admin";
const ctx = await requirePermission(Permissions.QUESTION_UPDATE);
const canEditAll = ctx.dataScope.type === "all";
const jsonString = formData.get("json");
if (typeof jsonString !== "string") {
@@ -182,7 +151,7 @@ export async function updateQuestionAction(
content: input.content,
updatedAt: new Date(),
})
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, user.id)));
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, ctx.userId)));
await tx
.delete(questionsToKnowledgePoints)
@@ -201,9 +170,12 @@ export async function updateQuestionAction(
revalidatePath("/teacher/questions");
return { success: true, message: "Question updated successfully" };
} catch (error) {
if (error instanceof Error) {
return { success: false, message: error.message };
} 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" };
}
@@ -227,8 +199,8 @@ export async function deleteQuestionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher();
const canDeleteAll = user.role === "admin";
const ctx = await requirePermission(Permissions.QUESTION_DELETE);
const canDeleteAll = ctx.dataScope.type === "all";
const questionId = formData.get("questionId");
if (typeof questionId !== "string") {
@@ -244,7 +216,7 @@ export async function deleteQuestionAction(
throw new Error("Question not found");
}
if (!canDeleteAll && q.authorId !== user.id) {
if (!canDeleteAll && q.authorId !== ctx.userId) {
throw new Error("Unauthorized");
}
@@ -254,21 +226,32 @@ export async function deleteQuestionAction(
revalidatePath("/teacher/questions");
return { success: true, message: "Question deleted successfully" };
} catch (error) {
if (error instanceof Error) {
return { success: false, message: error.message };
} 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" };
}
}
export async function getQuestionsAction(params: GetQuestionsParams) {
await ensureTeacher();
return await getQuestions(params);
try {
await requirePermission(Permissions.QUESTION_READ);
return await getQuestions(params);
} catch (e) {
if (e instanceof PermissionDeniedError) {
throw e;
}
throw e;
}
}
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
await ensureTeacher();
try {
await requirePermission(Permissions.QUESTION_READ);
const rows = await db
.select({
@@ -302,4 +285,10 @@ export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOp
subject: row.subject ?? null,
grade: row.grade ?? null,
}));
} catch (e) {
if (e instanceof PermissionDeniedError) {
throw e;
}
throw e;
}
}

View File

@@ -1,5 +1,7 @@
"use server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { revalidatePath } from "next/cache";
import {
createTextbook,
@@ -24,10 +26,14 @@ export async function reorderChaptersAction(
textbookId: string
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await reorderChapters(chapterId, newIndex, parentId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapters reordered successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to reorder chapters" };
}
}
@@ -60,13 +66,17 @@ export async function createTextbookAction(
}
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createTextbook(rawData);
revalidatePath("/teacher/textbooks");
return {
success: true,
message: "Textbook created successfully.",
};
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return {
success: false,
message: "Failed to create textbook.",
@@ -95,13 +105,17 @@ export async function updateTextbookAction(
}
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await updateTextbook(rawData);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return {
success: true,
message: "Textbook updated successfully.",
};
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return {
success: false,
message: "Failed to update textbook.",
@@ -113,13 +127,17 @@ export async function deleteTextbookAction(
textbookId: string
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_DELETE);
await deleteTextbook(textbookId);
revalidatePath("/teacher/textbooks");
return {
success: true,
message: "Textbook deleted successfully.",
};
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return {
success: false,
message: "Failed to delete textbook.",
@@ -138,6 +156,7 @@ export async function createChapterAction(
if (!title) return { success: false, message: "Title is required" };
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createChapter({
textbookId,
title,
@@ -146,7 +165,10 @@ export async function createChapterAction(
});
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter created successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to create chapter" };
}
}
@@ -157,10 +179,14 @@ export async function updateChapterContentAction(
textbookId: string
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await updateChapterContent({ chapterId, content });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to update content" };
}
}
@@ -170,10 +196,14 @@ export async function deleteChapterAction(
textbookId: string
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_DELETE);
await deleteChapter(chapterId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter deleted successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to delete chapter" };
}
}
@@ -191,10 +221,14 @@ export async function createKnowledgePointAction(
if (!name) return { success: false, message: "Name is required" };
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createKnowledgePoint({ name, description, anchorText, chapterId });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to create knowledge point" };
}
}
@@ -204,10 +238,14 @@ export async function deleteKnowledgePointAction(
textbookId: string
): Promise<ActionState> {
try {
await requirePermission(Permissions.TEXTBOOK_DELETE);
await deleteKnowledgePoint(kpId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point deleted successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to delete knowledge point" };
}
}
@@ -225,10 +263,14 @@ export async function updateKnowledgePointAction(
if (!name) return { success: false, message: "Name is required" };
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await updateKnowledgePoint({ id: kpId, name, description, anchorText });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point updated successfully" };
} catch {
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
}
return { success: false, message: "Failed to update knowledge point" };
}
}

View File

@@ -0,0 +1,181 @@
"use client"
import { useMemo } from "react"
import type { KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
interface GraphNode extends KnowledgePoint {
x: number
y: number
}
interface GraphEdge {
id: string
x1: number
y1: number
x2: number
y2: number
}
interface GraphLayout {
nodes: GraphNode[]
edges: GraphEdge[]
width: number
height: number
}
function computeGraphLayout(knowledgePoints: KnowledgePoint[]): GraphLayout {
if (knowledgePoints.length === 0) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const byId = new Map<string, KnowledgePoint>()
for (const kp of knowledgePoints) byId.set(kp.id, kp)
const children = new Map<string, string[]>()
const roots: string[] = []
for (const kp of knowledgePoints) {
if (kp.parentId && byId.has(kp.parentId)) {
const arr = children.get(kp.parentId) ?? []
arr.push(kp.id)
children.set(kp.parentId, arr)
} else {
roots.push(kp.id)
}
}
const levelMap = new Map<string, number>()
const levels: string[][] = []
const queue = [...roots].map((id) => ({ id, level: 0 }))
if (queue.length === 0) {
for (const kp of knowledgePoints) queue.push({ id: kp.id, level: 0 })
}
while (queue.length > 0) {
const item = queue.shift()
if (!item) continue
if (levelMap.has(item.id)) continue
levelMap.set(item.id, item.level)
if (!levels[item.level]) levels[item.level] = []
levels[item.level].push(item.id)
const kids = children.get(item.id) ?? []
for (const kid of kids) {
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
}
}
for (const kp of knowledgePoints) {
if (!levelMap.has(kp.id)) {
const level = levels.length
levelMap.set(kp.id, level)
if (!levels[level]) levels[level] = []
levels[level].push(kp.id)
}
}
const nodeWidth = 160
const nodeHeight = 52
const gapX = 40
const gapY = 90
const maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (nodeWidth + gapX) + gapX
const height = levels.length * (nodeHeight + gapY) + gapY
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = gapX + index * (nodeWidth + gapX)
const y = gapY + level * (nodeHeight + gapY)
positions.set(id, { x, y })
})
})
const nodes = knowledgePoints.map((kp) => {
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
return { ...kp, x: pos.x, y: pos.y }
})
const edges = knowledgePoints
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentPos = positions.get(kp.parentId as string)!
const childPos = positions.get(kp.id)!
return {
id: `${kp.parentId}-${kp.id}`,
x1: parentPos.x + nodeWidth / 2,
y1: parentPos.y + nodeHeight,
x2: childPos.x + nodeWidth / 2,
y2: childPos.y,
}
})
return { nodes, edges, width, height }
}
interface KnowledgeGraphProps {
knowledgePoints: KnowledgePoint[]
selectedId: string | null
onHighlight: (id: string) => void
}
export function KnowledgeGraph({ knowledgePoints, selectedId, onHighlight }: KnowledgeGraphProps) {
const graphLayout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
if (knowledgePoints.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
)
}
return (
<ScrollArea className="flex-1 h-full px-2">
<div
className="relative"
style={{ width: graphLayout.width, height: graphLayout.height }}
>
<svg
width={graphLayout.width}
height={graphLayout.height}
className="absolute inset-0"
>
{graphLayout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="hsl(var(--border))"
strokeWidth={2}
/>
))}
</svg>
{graphLayout.nodes.map((node) => (
<button
key={node.id}
type="button"
className={cn(
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
selectedId === node.id && "border-primary bg-primary/5"
)}
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
onClick={() => onHighlight(node.id)}
>
<div className="font-medium truncate">{node.name}</div>
{node.description && (
<div className="text-[10px] text-muted-foreground truncate">
{node.description}
</div>
)}
</button>
))}
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import type { KnowledgePoint } from "../types"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
interface KnowledgePointDialogsProps {
// Create KP dialog
createDialogOpen: boolean
setCreateDialogOpen: (open: boolean) => void
selectedText: string
isCreating: boolean
onCreateKnowledgePoint: (formData: FormData) => Promise<boolean | void>
// Edit KP dialog
editKpDialogOpen: boolean
setEditKpDialogOpen: (open: boolean) => void
editingKp: KnowledgePoint | null
isUpdatingKp: boolean
onUpdateKnowledgePoint: (formData: FormData) => Promise<void>
// Question dialog
questionDialogOpen: boolean
setQuestionDialogOpen: (open: boolean) => void
targetKpForQuestion: KnowledgePoint | null
}
export function KnowledgePointDialogs({
createDialogOpen,
setCreateDialogOpen,
selectedText,
isCreating,
onCreateKnowledgePoint,
editKpDialogOpen,
setEditKpDialogOpen,
editingKp,
isUpdatingKp,
onUpdateKnowledgePoint,
questionDialogOpen,
setQuestionDialogOpen,
targetKpForQuestion,
}: KnowledgePointDialogsProps) {
return (
<>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={selectedText} required />
</div>
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" placeholder="请输入描述..." />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={onUpdateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"></Label>
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
</div>
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
<div className="flex items-center justify-between">
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
()
</Label>
</div>
<div className="pt-2">
<Input
key={editingKp?.id}
id="edit-anchorText"
name="anchorText"
defaultValue={editingKp?.anchorText || editingKp?.name}
className="text-sm font-mono"
required
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
</Button>
<Button type="submit" disabled={isUpdatingKp}>
{isUpdatingKp ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CreateQuestionDialog
open={questionDialogOpen}
onOpenChange={setQuestionDialogOpen}
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
defaultType="text"
/>
</>
)
}

View File

@@ -0,0 +1,107 @@
"use client"
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
import type { KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
interface KnowledgePointListProps {
knowledgePoints: KnowledgePoint[]
canEdit: boolean
highlightedKpId: string | null
onHighlight: (id: string) => void
onEdit: (kp: KnowledgePoint) => void
onDelete: (kpId: string, e: React.MouseEvent) => void
onCreateQuestion: (kp: KnowledgePoint) => void
}
export function KnowledgePointList({
knowledgePoints,
canEdit,
highlightedKpId,
onHighlight,
onEdit,
onDelete,
onCreateQuestion,
}: KnowledgePointListProps) {
if (knowledgePoints.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
)
}
return (
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{knowledgePoints.map((kp) => (
<button
key={kp.id}
type="button"
className={cn(
"w-full text-left p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => onHighlight(kp.id)}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onCreateQuestion(kp)
}}
title="创建相关题目"
aria-label="创建相关题目"
>
<PlusCircle className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onEdit(kp)
}}
title="编辑知识点"
aria-label="编辑知识点"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => onDelete(kp.id, e)}
title="删除知识点"
aria-label="删除知识点"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{kp.description && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</button>
))}
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,170 @@
"use client"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import rehypeSanitize from "rehype-sanitize"
import { Edit2, Save, Plus } from "lucide-react"
import type { Chapter, KnowledgePoint } from "../types"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/components/ui/context-menu"
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
interface TextbookContentPanelProps {
selected: Chapter | null
isEditing: boolean
editContent: string
setEditContent: (content: string) => void
canEdit: boolean
knowledgePoints: KnowledgePoint[]
highlightedKpId: string | null
onHighlight: (id: string) => void
onSwitchToKnowledgeTab: () => void
contentRef: React.RefObject<HTMLDivElement | null>
onPointerDown: (e: React.PointerEvent) => void
onContextMenuChange: (open: boolean) => void
selectedText: string
createDialogOpen: boolean
setCreateDialogOpen: (open: boolean) => void
isCreating: boolean
onCreateKnowledgePoint: (formData: FormData) => Promise<boolean | void>
startEditing: () => void
cancelEditing: () => void
saveContent: () => void
isSaving: boolean
processedContent: string
}
export function TextbookContentPanel({
selected,
isEditing,
editContent,
setEditContent,
canEdit,
highlightedKpId,
onHighlight,
onSwitchToKnowledgeTab,
contentRef,
onPointerDown,
onContextMenuChange,
selectedText,
setCreateDialogOpen,
startEditing,
cancelEditing,
saveContent,
isSaving,
processedContent,
}: TextbookContentPanelProps) {
if (!selected) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
</div>
)
}
return (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={cancelEditing} disabled={isSaving}>
</Button>
<Button size="sm" onClick={saveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存"}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={startEditing}>
<Edit2 className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
{isEditing ? (
<div className="h-full">
<RichTextEditor
value={editContent}
onChange={setEditContent}
className="min-h-[500px] border-none shadow-none"
/>
</div>
) : (
<ContextMenu onOpenChange={onContextMenuChange}>
<ContextMenuTrigger asChild>
<div
className="p-4 min-h-full"
ref={contentRef}
onPointerDown={onPointerDown}
>
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ href, children, ...props }) => {
if (href?.startsWith("#kp-")) {
const id = href.replace("#kp-", "")
const isHighlighted = highlightedKpId === id
return (
<span
data-kp-id={id}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
)}
onClick={(e) => {
e.preventDefault()
onHighlight(id)
onSwitchToKnowledgeTab()
}}
title="点击查看知识点详情"
>
{children}
</span>
)
}
return <a href={href} {...props}>{children}</a>
}
}}
>
{processedContent}
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!selectedText}
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</ScrollArea>
</>
)
}

View File

@@ -1,42 +1,33 @@
"use client"
import { useMemo, useState, useEffect, useRef } from "react"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { useMemo, useState, useEffect } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
import { Tag, List, Share2 } from "lucide-react"
import { toast } from "sonner"
import type { Chapter, KnowledgePoint } from "../types"
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
import { cn } from "@/shared/lib/utils"
import { updateChapterContentAction } from "../actions"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/components/ui/context-menu"
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { ChapterSidebarList } from "./chapter-sidebar-list"
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
import { KnowledgeGraph } from "./knowledge-graph"
import { KnowledgePointList } from "./knowledge-point-list"
import { TextbookContentPanel } from "./textbook-content-panel"
import { KnowledgePointDialogs } from "./knowledge-point-dialogs"
import { useTextSelection } from "../hooks/use-text-selection"
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
function buildChapterIndex(chapters: Chapter[]) {
const index = new Map<string, Chapter>()
@@ -52,109 +43,81 @@ function buildChapterIndex(chapters: Chapter[]) {
return index
}
export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) {
export function TextbookReader({
chapters,
knowledgePoints = [],
canEdit = false,
textbookId,
}: {
chapters: Chapter[]
knowledgePoints?: KnowledgePoint[]
canEdit?: boolean
textbookId?: string
}) {
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
// Selection & Creation State
const [selectedText, setSelectedText] = useState("")
const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
// Editing State
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
const [isSaving, setIsSaving] = useState(false)
// Knowledge Point Edit State
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
// Question Creation State
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
const {
selectedText,
setSelectedText,
contentRef,
createDialogOpen,
setCreateDialogOpen,
isCreating,
setIsCreating,
handleContentPointerDown,
handleContextMenuChange,
} = useTextSelection()
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
const handleSelect = (chapter: Chapter) => {
setChapterId(chapter.id)
setIsEditing(false)
}
const currentChapterKPs = useMemo(() => {
if (!selectedId) return []
return knowledgePoints.filter((kp) => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
// Handle Text Selection via Context Menu
// We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs.
// Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases.
const handleContentPointerDown = (e: React.PointerEvent) => {
// Only capture on right click (button 2)
if (e.button !== 2) return
const {
editingKp,
setEditingKp,
editKpDialogOpen,
setEditKpDialogOpen,
isUpdatingKp,
questionDialogOpen,
setQuestionDialogOpen,
targetKpForQuestion,
setTargetKpForQuestion,
deleteConfirmOpen,
setDeleteConfirmOpen,
handleCreateKnowledgePoint,
requestDeleteKnowledgePoint,
confirmDeleteKnowledgePoint,
handleUpdateKnowledgePoint,
} = useKnowledgePointActions(
textbookId,
selectedId,
selected?.textbookId,
highlightedKpId,
setHighlightedKpId,
() => {
setCreateDialogOpen(false)
setActiveTab("knowledge")
setSelectedText("")
},
)
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
selectionRef.current = ""
return
}
// Check if selection is within content area
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
// Store in ref, don't trigger re-render yet
selectionRef.current = selection.toString().trim()
} else {
selectionRef.current = ""
}
}
const [localContent, setLocalContent] = useState<string | null>(null)
const handleContextMenuChange = (open: boolean) => {
if (!open) return
// When menu opens, sync ref to state to update UI
if (selectionRef.current) {
setSelectedText(selectionRef.current)
} else {
// Fallback: If pointer down didn't capture (e.g. keyboard), try now
const selection = window.getSelection()
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
const text = selection.toString().trim()
selectionRef.current = text
setSelectedText(text)
} else {
setSelectedText("")
}
}
}
const handleCreateKnowledgePoint = async (formData: FormData) => {
if (!selectedId || !selected) return
const onCreateKnowledgePoint = async (formData: FormData) => {
setIsCreating(true)
try {
const result = await createKnowledgePointAction(
selectedId,
selected.textbookId,
null,
formData
)
if (result.success) {
toast.success("知识点已创建")
setCreateDialogOpen(false)
setActiveTab("knowledge")
// Clear selection
window.getSelection()?.removeAllRanges()
setSelectedText("")
} else {
toast.error(result.message || "创建知识点失败")
}
} catch {
toast.error("发生错误")
} finally {
setIsCreating(false)
}
await handleCreateKnowledgePoint(formData)
setIsCreating(false)
}
const handleSaveContent = async () => {
@@ -162,23 +125,11 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
setIsSaving(true)
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
setIsSaving(false)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
// Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh
// For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server,
// we ideally want to trigger a refresh.
// However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update.
// But `chapters` prop won't update automatically unless we router.refresh().
// Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh.
// A full solution would use `router.refresh()`.
// For now, we can update the `selected.content` in place? No, it's a prop.
// We will rely on router refresh in the parent or just simple UI feedback.
// Actually, let's trigger a router refresh if possible, but we don't have router here.
// We'll just exit edit mode. The content might look old until refresh.
// To fix this, we can locally override content.
if (selected) selected.content = editContent
setLocalContent(editContent)
} else {
toast.error(result.message)
}
@@ -191,180 +142,33 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
}
}
const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm("确定要删除这个知识点吗?")) return
if (!textbookId) return
try {
const result = await deleteKnowledgePointAction(kpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === kpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error("删除失败")
}
const handleSelect = (chapter: Chapter) => {
setChapterId(chapter.id)
setIsEditing(false)
setLocalContent(null)
}
const handleUpdateKnowledgePoint = async (formData: FormData) => {
if (!editingKp || !textbookId) return
setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setEditKpDialogOpen(false)
setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error("更新失败")
} finally {
setIsUpdatingKp(false)
}
}
const effectiveContent = localContent ?? selected?.content
// Filter KPs for the current chapter
const currentChapterKPs = useMemo(() => {
if (!selectedId) return []
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
const graphLayout = useMemo(() => {
if (currentChapterKPs.length === 0) {
return { nodes: [], edges: [], width: 0, height: 0 }
}
const byId = new Map<string, KnowledgePoint>()
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
const children = new Map<string, string[]>()
const roots: string[] = []
for (const kp of currentChapterKPs) {
if (kp.parentId && byId.has(kp.parentId)) {
const arr = children.get(kp.parentId) ?? []
arr.push(kp.id)
children.set(kp.parentId, arr)
} else {
roots.push(kp.id)
}
}
const levelMap = new Map<string, number>()
const levels: string[][] = []
const queue = [...roots].map((id) => ({ id, level: 0 }))
if (queue.length === 0) {
for (const kp of currentChapterKPs) queue.push({ id: kp.id, level: 0 })
}
while (queue.length > 0) {
const item = queue.shift()
if (!item) continue
if (levelMap.has(item.id)) continue
levelMap.set(item.id, item.level)
if (!levels[item.level]) levels[item.level] = []
levels[item.level].push(item.id)
const kids = children.get(item.id) ?? []
for (const kid of kids) {
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
}
}
for (const kp of currentChapterKPs) {
if (!levelMap.has(kp.id)) {
const level = levels.length
levelMap.set(kp.id, level)
if (!levels[level]) levels[level] = []
levels[level].push(kp.id)
}
}
const nodeWidth = 160
const nodeHeight = 52
const gapX = 40
const gapY = 90
const maxCount = Math.max(...levels.map((l) => l.length), 1)
const width = maxCount * (nodeWidth + gapX) + gapX
const height = levels.length * (nodeHeight + gapY) + gapY
const positions = new Map<string, { x: number; y: number }>()
levels.forEach((ids, level) => {
ids.forEach((id, index) => {
const x = gapX + index * (nodeWidth + gapX)
const y = gapY + level * (nodeHeight + gapY)
positions.set(id, { x, y })
})
})
const nodes = currentChapterKPs.map((kp) => {
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
return { ...kp, x: pos.x, y: pos.y }
})
const edges = currentChapterKPs
.filter((kp) => kp.parentId && positions.has(kp.parentId))
.map((kp) => {
const parentPos = positions.get(kp.parentId as string)!
const childPos = positions.get(kp.id)!
return {
id: `${kp.parentId}-${kp.id}`,
x1: parentPos.x + nodeWidth / 2,
y1: parentPos.y + nodeHeight,
x2: childPos.x + nodeWidth / 2,
y2: childPos.y,
}
})
return { nodes, edges, width, height }
}, [currentChapterKPs])
// Pre-process content to mark knowledge points
const processedContent = useMemo(() => {
if (!selected?.content) return ""
let content = selected.content
// Sort KPs by name length descending to handle overlapping names
if (!effectiveContent) return ""
let content = effectiveContent
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
// We use a temporary replacement strategy to avoid nested replacements
// This is simple but works for most cases
// We replace "Name" with "[Name](kp://id)"
for (const kp of sortedKPs) {
// Escape regex special characters
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Case insensitive match, but preserve original text casing
// We use a simplified lookahead to avoid replacing inside existing links if possible,
// but perfect markdown parsing is hard with regex.
// For now, we assume KPs don't overlap in a way that breaks things often.
const regex = new RegExp(`(${escapedName})`, 'gi')
// We only replace if not already part of a link (simplified check)
// A robust parser would be better, but regex is acceptable for this level
content = content.replace(regex, `[$1](#kp-${kp.id})`)
}
return content
}, [selected?.content, currentChapterKPs])
// Scroll to highlighted KP
for (const kp of sortedKPs) {
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`(${escapedName})`, "gi")
content = content.replace(regex, `[$1](#kp-${kp.id})`)
}
return content
}, [effectiveContent, currentChapterKPs])
useEffect(() => {
if (highlightedKpId) {
// Find first element by data attribute
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" })
// Add temporary highlight effect
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
@@ -387,7 +191,9 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
<Tag className="h-4 w-4" />
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
@@ -396,97 +202,43 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{currentChapterKPs.map((kp) => (
<div
key={kp.id}
className={cn(
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => setHighlightedKpId(kp.id)}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
title="创建相关题目"
>
<PlusCircle className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
title="编辑知识点"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
title="删除知识点"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{kp.description && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
))}
</div>
</ScrollArea>
)}
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<KnowledgePointList
knowledgePoints={currentChapterKPs}
canEdit={canEdit}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onEdit={(kp) => {
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
onDelete={requestDeleteKnowledgePoint}
onCreateQuestion={(kp) => {
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
/>
)}
</TabsContent>
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
@@ -494,250 +246,73 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div
className="relative"
style={{ width: graphLayout.width, height: graphLayout.height }}
>
<svg
width={graphLayout.width}
height={graphLayout.height}
className="absolute inset-0"
>
{graphLayout.edges.map((edge) => (
<line
key={edge.id}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
stroke="hsl(var(--border))"
strokeWidth={2}
/>
))}
</svg>
{graphLayout.nodes.map((node) => (
<button
key={node.id}
type="button"
className={cn(
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
highlightedKpId === node.id && "border-primary bg-primary/5"
)}
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
onClick={() => setHighlightedKpId(node.id)}
>
<div className="font-medium truncate">{node.name}</div>
{node.description && (
<div className="text-[10px] text-muted-foreground truncate">
{node.description}
</div>
)}
</button>
))}
</div>
</ScrollArea>
<KnowledgeGraph
knowledgePoints={currentChapterKPs}
selectedId={highlightedKpId}
onHighlight={setHighlightedKpId}
/>
)}
</TabsContent>
</Tabs>
</div>
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleCreateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={selectedText} required />
</div>
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" placeholder="请输入描述..." />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleUpdateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"></Label>
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
</div>
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
<div className="flex items-center justify-between">
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
()
</Label>
</div>
<div className="pt-2">
<Input
key={editingKp?.id} // Force re-render when kp changes
id="edit-anchorText"
name="anchorText"
defaultValue={editingKp?.anchorText || editingKp?.name}
className="text-sm font-mono"
required
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
</Button>
<Button type="submit" disabled={isUpdatingKp}>
{isUpdatingKp ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CreateQuestionDialog
open={questionDialogOpen}
onOpenChange={setQuestionDialogOpen}
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
defaultType="text"
<KnowledgePointDialogs
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
selectedText={selectedText}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
editKpDialogOpen={editKpDialogOpen}
setEditKpDialogOpen={setEditKpDialogOpen}
editingKp={editingKp}
isUpdatingKp={isUpdatingKp}
onUpdateKnowledgePoint={handleUpdateKnowledgePoint}
questionDialogOpen={questionDialogOpen}
setQuestionDialogOpen={setQuestionDialogOpen}
targetKpForQuestion={targetKpForQuestion}
/>
{selected ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
</Button>
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存"}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={startEditing}>
<Edit2 className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
{isEditing ? (
<div className="h-full">
<RichTextEditor
value={editContent}
onChange={setEditContent}
className="min-h-[500px] border-none shadow-none"
/>
</div>
) : (
<ContextMenu onOpenChange={handleContextMenuChange}>
<ContextMenuTrigger asChild>
<div
className="p-4 min-h-full"
ref={contentRef}
onPointerDown={handleContentPointerDown}
>
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ href, children, ...props }) => {
if (href?.startsWith("#kp-")) {
const id = href.replace("#kp-", "")
const isHighlighted = highlightedKpId === id
return (
<span
data-kp-id={id}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
)}
onClick={(e) => {
e.preventDefault()
setHighlightedKpId(id)
setActiveTab("knowledge")
}}
title="点击查看知识点详情"
>
{children}
</span>
)
}
return <a href={href} {...props}>{children}</a>
}
}}
>
{processedContent}
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!selectedText}
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</ScrollArea>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
</div>
)}
<TextbookContentPanel
selected={selected}
isEditing={isEditing}
editContent={editContent}
setEditContent={setEditContent}
canEdit={canEdit}
knowledgePoints={currentChapterKPs}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
contentRef={contentRef}
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
createDialogOpen={createDialogOpen}
setCreateDialogOpen={setCreateDialogOpen}
isCreating={isCreating}
onCreateKnowledgePoint={onCreateKnowledgePoint}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}
isSaving={isSaving}
processedContent={processedContent}
/>
</div>
</div>
)

View File

@@ -0,0 +1,121 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import type { KnowledgePoint } from "../types"
import {
createKnowledgePointAction,
deleteKnowledgePointAction,
updateKnowledgePointAction,
} from "../actions"
export function useKnowledgePointActions(
textbookId: string | undefined,
selectedChapterId: string | null,
selectedChapterTextbookId: string | undefined,
highlightedKpId: string | null,
setHighlightedKpId: (id: string | null) => void,
onKpCreated?: () => void,
) {
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
const handleCreateKnowledgePoint = async (formData: FormData) => {
if (!selectedChapterId || !selectedChapterTextbookId) return
try {
const result = await createKnowledgePointAction(
selectedChapterId,
selectedChapterTextbookId,
null,
formData,
)
if (result.success) {
toast.success("知识点已创建")
onKpCreated?.()
window.getSelection()?.removeAllRanges()
return true
} else {
toast.error(result.message || "创建知识点失败")
return false
}
} catch {
toast.error("发生错误")
return false
}
}
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [pendingDeleteKpId, setPendingDeleteKpId] = useState<string | null>(null)
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent) => {
e.stopPropagation()
setPendingDeleteKpId(kpId)
setDeleteConfirmOpen(true)
}
const confirmDeleteKnowledgePoint = async () => {
if (!pendingDeleteKpId || !textbookId) return
setDeleteConfirmOpen(false)
try {
const result = await deleteKnowledgePointAction(pendingDeleteKpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === pendingDeleteKpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error("删除失败")
} finally {
setPendingDeleteKpId(null)
}
}
const handleUpdateKnowledgePoint = async (formData: FormData) => {
if (!editingKp || !textbookId) return
setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setEditKpDialogOpen(false)
setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error("更新失败")
} finally {
setIsUpdatingKp(false)
}
}
return {
editingKp,
setEditingKp,
editKpDialogOpen,
setEditKpDialogOpen,
isUpdatingKp,
questionDialogOpen,
setQuestionDialogOpen,
targetKpForQuestion,
setTargetKpForQuestion,
deleteConfirmOpen,
setDeleteConfirmOpen,
handleCreateKnowledgePoint,
requestDeleteKnowledgePoint,
confirmDeleteKnowledgePoint,
handleUpdateKnowledgePoint,
}
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useState, useRef } from "react"
export function useTextSelection() {
const [selectedText, setSelectedText] = useState("")
const selectionRef = useRef("")
const contentRef = useRef<HTMLDivElement>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const handleContentPointerDown = (e: React.PointerEvent) => {
if (e.button !== 2) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
selectionRef.current = ""
return
}
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
selectionRef.current = selection.toString().trim()
} else {
selectionRef.current = ""
}
}
const handleContextMenuChange = (open: boolean) => {
if (!open) return
if (selectionRef.current) {
setSelectedText(selectionRef.current)
} else {
const selection = window.getSelection()
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
const text = selection.toString().trim()
selectionRef.current = text
setSelectedText(text)
} else {
setSelectedText("")
}
}
}
return {
selectedText,
setSelectedText,
selectionRef,
contentRef,
createDialogOpen,
setCreateDialogOpen,
isCreating,
setIsCreating,
handleContentPointerDown,
handleContextMenuChange,
}
}

9
src/next-auth.d.ts vendored
View File

@@ -1,10 +1,13 @@
import type { DefaultSession } from "next-auth"
import type { Permission } from "@/shared/types/permissions"
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string
role: string
role: string // kept for backward compatibility
roles: string[]
permissions: Permission[]
}
}
}
@@ -12,6 +15,8 @@ declare module "next-auth" {
declare module "next-auth/jwt" {
interface JWT {
id: string
role: string
role: string // kept for backward compatibility
roles: string[]
permissions: Permission[]
}
}

View File

@@ -1,53 +1,80 @@
import { NextResponse } from "next/server"
import type { NextAuthRequest } from "next-auth"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { auth } from "./auth"
function normalizeRole(value: unknown) {
const role = String(value ?? "").trim().toLowerCase()
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
// Route prefix → minimum required permission
const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin": "school:manage",
"/teacher": "exam:read",
"/student": "homework:submit",
"/parent": "exam:read",
"/management": "grade:manage",
}
function roleHome(role: string) {
if (role === "admin") return "/admin/dashboard"
if (role === "student") return "/student/dashboard"
if (role === "parent") return "/parent/dashboard"
return "/teacher/dashboard"
// API route prefix → required permission
const API_PERMISSIONS: Record<string, string> = {
"/api/ai/chat": "ai:chat",
}
export default auth((req: NextAuthRequest) => {
const { pathname } = req.nextUrl
const session = req.auth
function resolveDefaultPath(roles: string[]): string {
if (roles.includes("admin")) return "/admin/dashboard"
if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard"
if (roles.includes("teacher")) return "/teacher/dashboard"
if (roles.includes("student")) return "/student/dashboard"
if (roles.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}
if (!session?.user) {
const url = req.nextUrl.clone()
url.pathname = "/login"
url.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(url)
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip static assets and auth pages
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api/auth") ||
pathname === "/login" ||
pathname === "/register" ||
pathname === "/favicon.ico"
) {
return NextResponse.next()
}
const role = normalizeRole(session.user.role)
const token = await getToken({ req: request })
if (pathname.startsWith("/admin/") && role !== "admin") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
// Not authenticated → redirect to login
if (!token) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", request.url)
return NextResponse.redirect(loginUrl)
}
if (pathname.startsWith("/teacher/") && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
const permissions: string[] = (token.permissions as string[]) ?? []
const roles: string[] = (token.roles as string[]) ?? []
// Check API route permissions
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
break
}
}
if (pathname.startsWith("/student/") && role !== "student") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/parent/") && role !== "parent") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/management/") && role !== "admin" && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
// Check page route permissions
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
return NextResponse.redirect(new URL(defaultPath, request.url))
}
break
}
}
return NextResponse.next()
})
}
export const config = {
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/management/:path*", "/settings/:path*", "/profile"],
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}

View File

@@ -13,6 +13,7 @@ import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { cn } from "@/shared/lib/utils"
import { Permissions } from "@/shared/types/permissions"
type Role = "student" | "teacher" | "parent" | "admin"
@@ -27,7 +28,6 @@ export function OnboardingGate() {
const router = useRouter()
const { status, data: session, update } = useSession()
const [required, setRequired] = useState(false)
const [currentRole, setCurrentRole] = useState<Role>("student")
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -53,7 +53,6 @@ export function OnboardingGate() {
const required = Boolean(json.required)
const role = String(json.role ?? "student") as Role
setRequired(required)
setCurrentRole(role)
setRole(role === "admin" ? "admin" : role)
setName(String(session?.user?.name ?? "").trim())
if (required) {
@@ -88,6 +87,12 @@ export function OnboardingGate() {
const canNextFromStep0 = role.length > 0
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
const permissions = (session?.user?.permissions ?? []) as string[]
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
const onNext = async () => {
if (step === 0) {
if (!canNextFromStep0) return
@@ -99,7 +104,7 @@ export function OnboardingGate() {
toast.error("请填写姓名与电话")
return
}
if (role === "admin") {
if (isAdmin) {
setStep(3)
} else {
setStep(2)
@@ -181,7 +186,7 @@ export function OnboardingGate() {
{step === 0 ? (
<div className="grid gap-2">
<Label>Role</Label>
{currentRole === "admin" ? (
{isAdmin ? (
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
) : (
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
@@ -217,7 +222,7 @@ export function OnboardingGate() {
{step === 2 ? (
<div className="grid gap-4">
{role === "teacher" ? (
{isTeacher ? (
<>
<div className="grid gap-2">
<Label htmlFor="onb_codes_teacher"></Label>
@@ -242,7 +247,7 @@ export function OnboardingGate() {
</>
) : null}
{role === "student" ? (
{isStudent ? (
<div className="grid gap-2">
<Label htmlFor="onb_codes_student"></Label>
<Textarea
@@ -254,7 +259,7 @@ export function OnboardingGate() {
</div>
) : null}
{role === "parent" ? (
{isParent ? (
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
</div>

View File

@@ -108,6 +108,15 @@ export const usersToRoles = mysqlTable("users_to_roles", {
userIdIdx: index("user_id_idx").on(table.userId),
}));
// Role -> Permissions (fine-grained RBAC)
export const rolePermissions = mysqlTable("role_permissions", {
roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 100 }).notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.roleId, table.permission] }),
roleIdIdx: index("role_permissions_role_idx").on(table.roleId),
}));
// --- 2. Knowledge Points (Tree Structure) ---
export const knowledgePoints = mysqlTable("knowledge_points", {

View File

@@ -0,0 +1,5 @@
export { useActionWithToast } from "./use-action-with-toast"
export { useDebounce } from "./use-debounce"
export { useMediaQuery } from "./use-media-query"
export { useLocalStorage } from "./use-local-storage"
export { usePermission } from "./use-permission"

View File

@@ -0,0 +1,22 @@
"use client"
import { useTransition } from "react"
import { toast } from "sonner"
import type { ActionState } from "@/shared/types/action-state"
export function useActionWithToast<T>() {
const [isPending, startTransition] = useTransition()
const execute = async (action: () => Promise<ActionState<T>>) => {
startTransition(async () => {
const result = await action()
if (result.success) {
toast.success(result.message || "操作成功")
} else {
toast.error(result.message || "操作失败")
}
})
}
return { isPending, execute }
}

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from "vitest"
import { renderHook, act } from "@testing-library/react"
import { useDebounce } from "./use-debounce"
describe("useDebounce", () => {
it("should return initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500))
expect(result.current).toBe("hello")
})
it("should debounce value changes", () => {
vi.useFakeTimers()
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 300 } }
)
rerender({ value: "world", delay: 300 })
expect(result.current).toBe("hello")
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current).toBe("world")
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,14 @@
"use client"
import { useState, useEffect } from "react"
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
import { useLocalStorage } from "./use-local-storage"
describe("useLocalStorage", () => {
beforeEach(() => {
localStorage.clear()
})
it("should return initial value when localStorage is empty", () => {
const { result } = renderHook(() => useLocalStorage("test-key", "default"))
expect(result.current[0]).toBe("default")
})
it("should persist value to localStorage", () => {
const { result } = renderHook(() => useLocalStorage("test-key", "default"))
act(() => {
result.current[1]("updated")
})
expect(result.current[0]).toBe("updated")
expect(localStorage.getItem("test-key")).toBe(JSON.stringify("updated"))
})
it("should support functional updates", () => {
const { result } = renderHook(() => useLocalStorage("test-key", 0))
act(() => {
result.current[1]((prev) => prev + 1)
})
expect(result.current[0]).toBe(1)
})
})

View File

@@ -0,0 +1,30 @@
"use client"
import { useState, useCallback } from "react"
function getStorageItem<T>(key: string, initialValue: T): T {
try {
const item = window.localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : initialValue
} catch {
return initialValue
}
}
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [localValue, setLocalValue] = useState<T>(() => getStorageItem(key, initialValue))
const setValue = useCallback((newValue: T | ((prev: T) => T)) => {
setLocalValue((prev) => {
const nextValue = newValue instanceof Function ? newValue(prev) : newValue
try {
window.localStorage.setItem(key, JSON.stringify(nextValue))
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
return nextValue
})
}, [key])
return [localValue, setValue]
}

View File

@@ -0,0 +1,14 @@
"use client"
import { useSyncExternalStore } from "react"
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(callback) => {
const media = window.matchMedia(query)
media.addEventListener("change", callback)
return () => media.removeEventListener("change", callback)
},
() => window.matchMedia(query).matches,
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useSession } from "next-auth/react"
import type { Permission } from "@/shared/types/permissions"
export function usePermission() {
const { data: session } = useSession()
const permissions = (session?.user?.permissions ?? []) as Permission[]
const roles = (session?.user?.roles ?? []) as string[]
const hasPermission = (permission: Permission): boolean => {
return permissions.includes(permission)
}
const hasAnyPermission = (...perms: Permission[]): boolean => {
return perms.some((p) => permissions.includes(p))
}
const hasAllPermissions = (...perms: Permission[]): boolean => {
return perms.every((p) => permissions.includes(p))
}
const hasRole = (role: string): boolean => {
return roles.includes(role)
}
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
}

View File

@@ -0,0 +1,133 @@
import { auth } from "@/auth"
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
classSubjectTeachers,
grades,
} from "@/shared/db/schema"
import { eq, or } from "drizzle-orm"
export class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
}
/**
* Get the full authentication context for the current user.
* Throws if not authenticated.
*/
export async function getAuthContext(): Promise<AuthContext> {
const session = await auth()
const userId = session?.user?.id
if (!userId) throw new PermissionDeniedError("auth_required")
// Prefer session data (already resolved in JWT callback)
const roleNames = (session.user.roles ?? []) as string[]
const permissions = (session.user.permissions ?? []) as Permission[]
// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)
return { userId, roles: roleNames, permissions, dataScope }
}
/**
* Assert the current user has the specified permission.
* Returns AuthContext on success, throws PermissionDeniedError on failure.
*/
export async function requirePermission(permission: Permission): Promise<AuthContext> {
const ctx = await getAuthContext()
if (!ctx.permissions.includes(permission)) {
throw new PermissionDeniedError(permission)
}
return ctx
}
/**
* Check permission without throwing. Useful for conditional logic.
*/
export async function checkPermission(
permission: Permission
): Promise<{ allowed: boolean; ctx: AuthContext }> {
const ctx = await getAuthContext()
return { allowed: ctx.permissions.includes(permission), ctx }
}
/**
* Resolve the data scope for a user based on their roles.
* Queries the DB for resource ownership information.
*/
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
// Admin sees everything
if (roleNames.includes("admin")) {
return { type: "all" }
}
// Grade head / teaching head: can manage their grades
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
const managedGrades = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGrades.length > 0) {
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
}
}
// Teacher: can see their own classes
if (roleNames.includes("teacher")) {
// Classes where user is the homeroom teacher
const homeroomClasses = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, userId))
// Classes where user is a subject teacher
const subjectClasses = await db
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, userId))
const classIds = [
...new Set([
...homeroomClasses.map((c) => c.id),
...subjectClasses.map((c) => c.classId),
]),
]
const subjectIds = subjectClasses
.map((c) => c.subjectId)
.filter((s): s is string => s !== null)
return {
type: "class_taught",
classIds,
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
}
}
// Student: can see data from their enrolled classes
if (roleNames.includes("student")) {
return { type: "class_members" }
}
// Parent: can see their children's data
if (roleNames.includes("parent")) {
// TODO: implement parent-child relationship lookup
return { type: "children", childrenIds: [] }
}
// Fallback: only own data
return { type: "owned", userId }
}
/**
* Convenience: assert the user is authenticated (has any role).
* Returns AuthContext on success.
*/
export async function requireAuth(): Promise<AuthContext> {
return getAuthContext()
}

View File

@@ -0,0 +1,130 @@
import { Permissions, type Permission } from "@/shared/types/permissions"
// Role → Permission mapping
// New roles only need to add an entry here + seed the DB
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_DUPLICATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE,
Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE,
Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE,
Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.TEXTBOOK_DELETE,
Permissions.CLASS_CREATE,
Permissions.CLASS_READ,
Permissions.CLASS_UPDATE,
Permissions.CLASS_DELETE,
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.SCHOOL_MANAGE,
Permissions.GRADE_MANAGE,
Permissions.USER_MANAGE,
Permissions.AI_CHAT,
Permissions.AI_CONFIGURE,
Permissions.SETTINGS_ADMIN,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_DUPLICATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE,
Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE,
Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE,
Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.CLASS_READ,
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.AI_CHAT,
],
student: [
Permissions.EXAM_READ,
Permissions.HOMEWORK_SUBMIT,
Permissions.QUESTION_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.AI_CHAT,
],
parent: [
Permissions.EXAM_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
],
grade_head: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_DUPLICATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE,
Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE,
Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE,
Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.CLASS_CREATE,
Permissions.CLASS_READ,
Permissions.CLASS_UPDATE,
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
],
teaching_head: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_DUPLICATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE,
Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE,
Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE,
Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.CLASS_READ,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
],
}
/**
* Merge permissions from all roles (deduplicated)
*/
export function resolvePermissions(roleNames: string[]): Permission[] {
const set = new Set<Permission>()
for (const name of roleNames) {
const perms = ROLE_PERMISSIONS[name] ?? []
for (const p of perms) set.add(p)
}
return Array.from(set)
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest"
import { cn, formatDate } from "./utils"
describe("cn", () => {
it("should merge class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")
})
it("should handle conditional classes", () => {
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
})
it("should resolve tailwind conflicts", () => {
expect(cn("px-4", "px-6")).toBe("px-6")
})
it("should handle undefined and null", () => {
expect(cn("foo", undefined, null, "bar")).toBe("foo bar")
})
})
describe("formatDate", () => {
it("should format a date string with default zh-CN locale", () => {
const result = formatDate("2024-01-15")
expect(result).toContain("2024")
expect(result).toContain("1")
})
it("should format a date string with en-US locale", () => {
const result = formatDate("2024-01-15", "en-US")
expect(result).toContain("2024")
expect(result).toContain("Jan")
})
it("should format a Date object", () => {
const result = formatDate(new Date(2024, 5, 15))
expect(result).toContain("2024")
})
})

View File

@@ -5,10 +5,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString("en-US", {
export function formatDate(date: string | Date, locale: string = "zh-CN") {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
})
}).format(new Date(date))
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest"
import type { ActionState } from "./action-state"
describe("ActionState", () => {
it("should create a success state", () => {
const state: ActionState<string> = {
success: true,
message: "Operation succeeded",
data: "result",
}
expect(state.success).toBe(true)
expect(state.data).toBe("result")
})
it("should create an error state", () => {
const state: ActionState = {
success: false,
message: "Operation failed",
errors: { field: ["Error message"] },
}
expect(state.success).toBe(false)
expect(state.errors).toBeDefined()
})
it("should create a void state", () => {
const state: ActionState = {
success: true,
message: "Done",
}
expect(state.success).toBe(true)
expect(state.data).toBeUndefined()
})
})

View File

@@ -0,0 +1,68 @@
// Permission definitions: resource:action naming convention
// Used by requirePermission() on server and usePermission() on client
export const Permissions = {
// Exam
EXAM_CREATE: "exam:create",
EXAM_READ: "exam:read",
EXAM_UPDATE: "exam:update",
EXAM_DELETE: "exam:delete",
EXAM_DUPLICATE: "exam:duplicate",
EXAM_PUBLISH: "exam:publish",
EXAM_AI_GENERATE: "exam:ai_generate",
// Homework
HOMEWORK_CREATE: "homework:create",
HOMEWORK_GRADE: "homework:grade",
HOMEWORK_SUBMIT: "homework:submit",
// Question
QUESTION_CREATE: "question:create",
QUESTION_READ: "question:read",
QUESTION_UPDATE: "question:update",
QUESTION_DELETE: "question:delete",
// Textbook
TEXTBOOK_CREATE: "textbook:create",
TEXTBOOK_READ: "textbook:read",
TEXTBOOK_UPDATE: "textbook:update",
TEXTBOOK_DELETE: "textbook:delete",
// Class
CLASS_CREATE: "class:create",
CLASS_READ: "class:read",
CLASS_UPDATE: "class:update",
CLASS_DELETE: "class:delete",
CLASS_ENROLL: "class:enroll",
CLASS_SCHEDULE: "class:schedule",
// School management
SCHOOL_MANAGE: "school:manage",
GRADE_MANAGE: "grade:manage",
USER_MANAGE: "user:manage",
// AI
AI_CHAT: "ai:chat",
AI_CONFIGURE: "ai:configure",
// Settings
SETTINGS_ADMIN: "settings:admin",
} as const
export type Permission = (typeof Permissions)[keyof typeof Permissions]
// Data scope for row-level security
export type DataScope =
| { type: "all" }
| { type: "owned"; userId: string }
| { type: "class_members" }
| { type: "grade_managed"; gradeIds: string[] }
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
| { type: "children"; childrenIds: string[] }
export interface AuthContext {
userId: string
roles: string[]
permissions: Permission[]
dataScope: DataScope
}