refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled
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:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
223
src/modules/exams/components/exam-ai-generator.tsx
Normal file
223
src/modules/exams/components/exam-ai-generator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
src/modules/exams/components/exam-basic-info-form.tsx
Normal file
190
src/modules/exams/components/exam-basic-info-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/modules/exams/components/exam-form-types.test.ts
Normal file
46
src/modules/exams/components/exam-form-types.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
133
src/modules/exams/components/exam-form-types.ts
Normal file
133
src/modules/exams/components/exam-form-types.ts
Normal 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
85
src/modules/exams/components/exam-mode-selector.tsx
Normal file
85
src/modules/exams/components/exam-mode-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
src/modules/exams/components/exam-preview-dialog.tsx
Normal file
185
src/modules/exams/components/exam-preview-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/modules/exams/components/exam-preview-question-editor.tsx
Normal file
173
src/modules/exams/components/exam-preview-question-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
src/modules/exams/components/exam-preview-utils.ts
Normal file
205
src/modules/exams/components/exam-preview-utils.ts
Normal 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) }
|
||||
}
|
||||
130
src/modules/exams/components/question-options-editor.tsx
Normal file
130
src/modules/exams/components/question-options-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
src/modules/exams/components/question-sub-questions-editor.tsx
Normal file
137
src/modules/exams/components/question-sub-questions-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
295
src/modules/exams/hooks/use-exam-preview.ts
Normal file
295
src/modules/exams/hooks/use-exam-preview.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Exam, ExamSubmission } from "./types"
|
||||
|
||||
export let MOCK_EXAMS: Exam[] = [
|
||||
{
|
||||
id: "exam_001",
|
||||
title: "Algebra Midterm",
|
||||
subject: "Mathematics",
|
||||
grade: "Grade 10",
|
||||
status: "draft",
|
||||
difficulty: 3,
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
questionCount: 25,
|
||||
scheduledAt: undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Algebra", "Functions"],
|
||||
},
|
||||
{
|
||||
id: "exam_002",
|
||||
title: "Physics Mechanics Quiz",
|
||||
subject: "Physics",
|
||||
grade: "Grade 11",
|
||||
status: "published",
|
||||
difficulty: 4,
|
||||
totalScore: 50,
|
||||
durationMin: 45,
|
||||
questionCount: 15,
|
||||
scheduledAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Mechanics", "Kinematics"],
|
||||
},
|
||||
{
|
||||
id: "exam_003",
|
||||
title: "English Reading Comprehension",
|
||||
subject: "English",
|
||||
grade: "Grade 12",
|
||||
status: "published",
|
||||
difficulty: 2,
|
||||
totalScore: 80,
|
||||
durationMin: 60,
|
||||
questionCount: 20,
|
||||
scheduledAt: new Date(Date.now() + 2 * 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Reading", "Vocabulary"],
|
||||
},
|
||||
{
|
||||
id: "exam_004",
|
||||
title: "Chemistry Final",
|
||||
subject: "Chemistry",
|
||||
grade: "Grade 12",
|
||||
status: "archived",
|
||||
difficulty: 5,
|
||||
totalScore: 120,
|
||||
durationMin: 120,
|
||||
questionCount: 40,
|
||||
scheduledAt: new Date(Date.now() - 30 * 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Organic", "Inorganic"],
|
||||
},
|
||||
{
|
||||
id: "exam_005",
|
||||
title: "Geometry Chapter Test",
|
||||
subject: "Mathematics",
|
||||
grade: "Grade 9",
|
||||
status: "published",
|
||||
difficulty: 3,
|
||||
totalScore: 60,
|
||||
durationMin: 50,
|
||||
questionCount: 18,
|
||||
scheduledAt: new Date(Date.now() + 3 * 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Geometry", "Triangles"],
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_SUBMISSIONS: ExamSubmission[] = [
|
||||
{
|
||||
id: "sub_001",
|
||||
examId: "exam_002",
|
||||
examTitle: "Physics Mechanics Quiz",
|
||||
studentName: "Alice Zhang",
|
||||
submittedAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "sub_002",
|
||||
examId: "exam_003",
|
||||
examTitle: "English Reading Comprehension",
|
||||
studentName: "Bob Li",
|
||||
submittedAt: new Date().toISOString(),
|
||||
score: 72,
|
||||
status: "graded",
|
||||
},
|
||||
]
|
||||
|
||||
export function addMockExam(exam: Exam) {
|
||||
MOCK_EXAMS = [exam, ...MOCK_EXAMS]
|
||||
}
|
||||
|
||||
export function updateMockExam(id: string, updates: Partial<Exam>) {
|
||||
MOCK_EXAMS = MOCK_EXAMS.map((e) => (e.id === id ? { ...e, ...updates } : e))
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Question } from "./types";
|
||||
|
||||
export const MOCK_QUESTIONS: Question[] = [
|
||||
{
|
||||
id: "q-001",
|
||||
content: "What is the capital of France?",
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-01"),
|
||||
updatedAt: new Date("2023-11-01"),
|
||||
author: { id: "u-1", name: "Alice Teacher", image: null },
|
||||
knowledgePoints: [{ id: "kp-1", name: "Geography" }, { id: "kp-2", name: "Europe" }],
|
||||
},
|
||||
{
|
||||
id: "q-002",
|
||||
content: "Explain the theory of relativity in simple terms.",
|
||||
type: "text",
|
||||
difficulty: 5,
|
||||
createdAt: new Date("2023-11-02"),
|
||||
updatedAt: new Date("2023-11-02"),
|
||||
author: { id: "u-2", name: "Bob Physicist", image: null },
|
||||
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
|
||||
},
|
||||
{
|
||||
id: "q-003",
|
||||
content: "True or False: The earth is flat.",
|
||||
type: "judgment",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-03"),
|
||||
updatedAt: new Date("2023-11-03"),
|
||||
author: { id: "u-1", name: "Alice Teacher", image: null },
|
||||
knowledgePoints: [{ id: "kp-1", name: "Geography" }],
|
||||
},
|
||||
{
|
||||
id: "q-004",
|
||||
content: "Select all prime numbers below 10.",
|
||||
type: "multiple_choice",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-04"),
|
||||
updatedAt: new Date("2023-11-04"),
|
||||
author: { id: "u-3", name: "Charlie Math", image: null },
|
||||
knowledgePoints: [{ id: "kp-4", name: "Math" }],
|
||||
},
|
||||
{
|
||||
id: "q-005",
|
||||
content: "Write a function to reverse a string in JavaScript.",
|
||||
type: "text",
|
||||
difficulty: 3,
|
||||
createdAt: new Date("2023-11-05"),
|
||||
updatedAt: new Date("2023-11-05"),
|
||||
author: { id: "u-4", name: "Dave Coder", image: null },
|
||||
knowledgePoints: [{ id: "kp-5", name: "Programming" }, { id: "kp-6", name: "JavaScript" }],
|
||||
},
|
||||
{
|
||||
id: "q-006",
|
||||
content: "Which of the following are fruits? (Apple, Carrot, Banana, Potato)",
|
||||
type: "multiple_choice",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-06"),
|
||||
updatedAt: new Date("2023-11-06"),
|
||||
author: { id: "u-1", name: "Alice Teacher", image: null },
|
||||
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
|
||||
},
|
||||
{
|
||||
id: "q-007",
|
||||
content: "Water boils at 100 degrees Celsius at sea level.",
|
||||
type: "judgment",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-07"),
|
||||
updatedAt: new Date("2023-11-07"),
|
||||
author: { id: "u-2", name: "Bob Physicist", image: null },
|
||||
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
|
||||
},
|
||||
{
|
||||
id: "q-008",
|
||||
content: "What is the powerhouse of the cell?",
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-08"),
|
||||
updatedAt: new Date("2023-11-08"),
|
||||
author: { id: "u-5", name: "Eve Biologist", image: null },
|
||||
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
|
||||
},
|
||||
{
|
||||
id: "q-009",
|
||||
content: "Solve for x: 2x + 5 = 15",
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-09"),
|
||||
updatedAt: new Date("2023-11-09"),
|
||||
author: { id: "u-3", name: "Charlie Math", image: null },
|
||||
knowledgePoints: [{ id: "kp-4", name: "Math" }],
|
||||
},
|
||||
{
|
||||
id: "q-010",
|
||||
content: "Describe the impact of the Industrial Revolution.",
|
||||
type: "text",
|
||||
difficulty: 4,
|
||||
createdAt: new Date("2023-11-10"),
|
||||
updatedAt: new Date("2023-11-10"),
|
||||
author: { id: "u-6", name: "Frank Historian", image: null },
|
||||
knowledgePoints: [{ id: "kp-8", name: "History" }],
|
||||
},
|
||||
{
|
||||
id: "q-011",
|
||||
content: "Light travels faster than sound.",
|
||||
type: "judgment",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-11"),
|
||||
updatedAt: new Date("2023-11-11"),
|
||||
author: { id: "u-2", name: "Bob Physicist", image: null },
|
||||
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
|
||||
},
|
||||
{
|
||||
id: "q-012",
|
||||
content: "Which element has the chemical symbol 'O'?",
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-12"),
|
||||
updatedAt: new Date("2023-11-12"),
|
||||
author: { id: "u-7", name: "Grace Chemist", image: null },
|
||||
knowledgePoints: [{ id: "kp-9", name: "Chemistry" }],
|
||||
},
|
||||
];
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
181
src/modules/textbooks/components/knowledge-graph.tsx
Normal file
181
src/modules/textbooks/components/knowledge-graph.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
src/modules/textbooks/components/knowledge-point-dialogs.tsx
Normal file
148
src/modules/textbooks/components/knowledge-point-dialogs.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
src/modules/textbooks/components/knowledge-point-list.tsx
Normal file
107
src/modules/textbooks/components/knowledge-point-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
src/modules/textbooks/components/textbook-content-panel.tsx
Normal file
170
src/modules/textbooks/components/textbook-content-panel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
121
src/modules/textbooks/hooks/use-knowledge-point-actions.ts
Normal file
121
src/modules/textbooks/hooks/use-knowledge-point-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
57
src/modules/textbooks/hooks/use-text-selection.ts
Normal file
57
src/modules/textbooks/hooks/use-text-selection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user