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:
SpecialX
2026-07-03 10:26:00 +08:00
parent 138b6f1b00
commit f3c223d914
77 changed files with 5397 additions and 2864 deletions

View File

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