feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks
This commit is contained in:
@@ -10,13 +10,23 @@ import { z } from "zod"
|
||||
import {
|
||||
createQuestionWithRelations,
|
||||
deleteQuestionByIdRecursive,
|
||||
deleteQuestionsBatch,
|
||||
exportQuestions,
|
||||
getChapterOptions,
|
||||
getKnowledgePointOptions,
|
||||
getKnowledgePointOptionsByChapter,
|
||||
getQuestions,
|
||||
getTextbookOptions,
|
||||
importQuestions,
|
||||
updateQuestionById,
|
||||
type GetQuestionsParams,
|
||||
type QuestionExportItem,
|
||||
type QuestionImportItem,
|
||||
} from "./data-access"
|
||||
import type { KnowledgePointOption } from "./types"
|
||||
import type { ChapterOption, KnowledgePointOption, TextbookOption } from "./types"
|
||||
import { QuestionTypeEnum } from "./schema"
|
||||
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||
import { trackQuestionCreated, trackQuestionUpdated, trackQuestionDeleted } from "./utils/track-event"
|
||||
|
||||
/** Result type of getQuestions (data + meta) */
|
||||
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
||||
@@ -56,7 +66,10 @@ export async function createQuestionAction(
|
||||
|
||||
const questionId = await createQuestionWithRelations(input, ctx.userId)
|
||||
|
||||
trackQuestionCreated(questionId, input.type, input.difficulty)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -102,7 +115,10 @@ export async function updateQuestionAction(
|
||||
|
||||
await updateQuestionById(id, updateData, canEditAll, ctx.userId)
|
||||
|
||||
trackQuestionUpdated(id, updateData.type, updateData.difficulty)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Question updated successfully", data: id }
|
||||
} catch (e) {
|
||||
@@ -125,7 +141,10 @@ export async function deleteQuestionAction(
|
||||
|
||||
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId)
|
||||
|
||||
trackQuestionDeleted(questionId)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Question deleted successfully", data: questionId }
|
||||
} catch (e) {
|
||||
@@ -133,6 +152,53 @@ export async function deleteQuestionAction(
|
||||
}
|
||||
}
|
||||
|
||||
const BatchDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1, "At least one question ID is required"),
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量删除题目。
|
||||
*
|
||||
* 输入 JSON:{ ids: string[] }
|
||||
* 权限:QUESTION_DELETE,遵循数据范围过滤。
|
||||
*/
|
||||
export async function deleteQuestionsBatchAction(
|
||||
prevState: ActionState<{ deleted: number }> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ deleted: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.QUESTION_DELETE)
|
||||
const canDeleteAll = ctx.dataScope.type === "all"
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||
}
|
||||
|
||||
const parsed = BatchDeleteSchema.safeParse(safeJsonParse<unknown>(jsonString, "批量删除参数无效"))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const deletedCount = await deleteQuestionsBatch(parsed.data.ids, canDeleteAll, ctx.userId)
|
||||
|
||||
for (const id of parsed.data.ids) {
|
||||
trackQuestionDeleted(id)
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Batch delete completed", data: { deleted: deletedCount } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuestionsAction(
|
||||
params: GetQuestionsParams,
|
||||
): Promise<ActionState<QuestionsListResult>> {
|
||||
@@ -156,3 +222,158 @@ export async function getKnowledgePointOptionsAction(): Promise<
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Result type of getTextbookOptions */
|
||||
type TextbookOptionsResult = TextbookOption[]
|
||||
|
||||
/** Result type of getChapterOptions */
|
||||
type ChapterOptionsResult = ChapterOption[]
|
||||
|
||||
/** Result type of getKnowledgePointOptionsByChapter */
|
||||
type KnowledgePointOptionsByChapterResult = { id: string; name: string }[]
|
||||
|
||||
/**
|
||||
* 获取教材选项列表(级联筛选第一级)。
|
||||
*/
|
||||
export async function getTextbookOptionsAction(): Promise<
|
||||
ActionState<TextbookOptionsResult>
|
||||
> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
const data = await getTextbookOptions()
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定教材下的章节选项列表(级联筛选第二级)。
|
||||
*/
|
||||
export async function getChapterOptionsAction(
|
||||
textbookId: string,
|
||||
): Promise<ActionState<ChapterOptionsResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
const data = await getChapterOptions(textbookId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定章节下的知识点选项列表(级联筛选第三级)。
|
||||
*/
|
||||
export async function getKnowledgePointOptionsByChapterAction(
|
||||
chapterId: string,
|
||||
): Promise<ActionState<KnowledgePointOptionsByChapterResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
const data = await getKnowledgePointOptionsByChapter(chapterId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 导入/导出 Server Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ExportSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 导出题目为 JSON 格式。
|
||||
*
|
||||
* 输入 JSON:{ ids?: string[] }(不传 ids 则导出全部,受权限范围限制)
|
||||
* 权限:QUESTION_READ,遵循数据范围过滤。
|
||||
*/
|
||||
export async function exportQuestionsAction(
|
||||
formData: FormData,
|
||||
): Promise<ActionState<QuestionExportItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.QUESTION_READ)
|
||||
const canExportAll = ctx.dataScope.type === "all"
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
const inputJson = typeof jsonString === "string" ? jsonString : "{}"
|
||||
const parsed = ExportSchema.safeParse(safeJsonParse<unknown>(inputJson, "导出参数无效"))
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const data = await exportQuestions(parsed.data.ids, canExportAll, ctx.userId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const ImportItemSchema = z.object({
|
||||
type: QuestionTypeEnum,
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.unknown(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const ImportSchema = z.object({
|
||||
items: z.array(ImportItemSchema).min(1, "至少需要一条题目"),
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量导入题目。
|
||||
*
|
||||
* 输入 JSON:{ items: QuestionImportItem[] }
|
||||
* 权限:QUESTION_CREATE。
|
||||
*/
|
||||
export async function importQuestionsAction(
|
||||
prevState: ActionState<{ imported: number }> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ imported: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.QUESTION_CREATE)
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||
}
|
||||
|
||||
const parsed = ImportSchema.safeParse(safeJsonParse<unknown>(jsonString, "导入参数无效"))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const items: QuestionImportItem[] = parsed.data.items.map((item) => ({
|
||||
type: item.type,
|
||||
difficulty: item.difficulty,
|
||||
content: item.content,
|
||||
knowledgePointIds: item.knowledgePointIds,
|
||||
}))
|
||||
|
||||
const createdIds = await importQuestions(items, ctx.userId)
|
||||
|
||||
for (let i = 0; i < createdIds.length; i++) {
|
||||
const item = items[i]
|
||||
trackQuestionCreated(createdIds[i], item.type, item.difficulty)
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Import completed", data: { imported: createdIds.length } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user