From e9a5264fe725ce54296f9ff3725a0ffb928e68f6 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:26:12 +0800 Subject: [PATCH] feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence --- src/modules/adaptive-practice/actions.ts | 213 +++++--- .../components/answer-input.tsx | 145 ++++++ .../components/answer-result.tsx | 76 +++ .../components/practice-history.tsx | 93 ++-- .../components/practice-result-view.tsx | 110 +++++ .../components/practice-session-view.tsx | 463 ++++-------------- .../components/practice-starter-with-nav.tsx | 45 ++ .../components/practice-starter.tsx | 266 ++++++---- .../components/question-card.tsx | 149 ++++++ .../components/question-content.tsx | 69 +++ .../data-access-analytics.ts | 170 ++++--- .../adaptive-practice/data-access-strategy.ts | 69 ++- src/modules/adaptive-practice/data-access.ts | 359 ++++++-------- .../adaptive-practice/lib/answer-utils.ts | 13 + src/modules/adaptive-practice/lib/errors.ts | 58 +++ src/modules/adaptive-practice/lib/grading.ts | 142 ++++++ .../adaptive-practice/lib/source-meta.ts | 114 +++++ .../adaptive-practice/lib/type-guards.ts | 105 ++++ src/modules/adaptive-practice/schema.ts | 63 ++- .../services/practice-service.tsx | 160 ++++++ src/modules/adaptive-practice/types.ts | 19 +- src/modules/ai/actions.ts | 56 ++- .../ai/components/ai-assistant-widget.tsx | 30 +- .../ai/components/ai-chart-renderer.tsx | 16 +- .../ai/components/ai-error-boundary.tsx | 89 +--- .../ai/components/ai-markdown-renderer.tsx | 29 +- .../ai/components/ai-provider-selector.tsx | 2 +- .../ai/components/ai-suggestion-card.tsx | 164 ------- .../ai/components/ai-usage-dashboard.tsx | 13 +- .../ai/context/create-ai-client-service.ts | 54 ++ src/modules/ai/hooks/use-ai-chat.ts | 57 --- src/modules/ai/hooks/use-drag-position.ts | 130 +++++ src/modules/ai/hooks/use-floating-ball.ts | 245 +++------ .../ai/hooks/use-position-persistence.ts | 99 ++++ src/modules/ai/schema.ts | 47 ++ src/modules/ai/services/ai-service.ts | 49 +- src/modules/ai/services/prompt-templates.ts | 23 + src/modules/ai/services/usage-tracker.ts | 3 +- src/modules/ai/types.ts | 35 ++ src/modules/auth/actions.ts | 382 +++++++++++++++ src/modules/auth/components/auth-layout.tsx | 28 +- src/modules/auth/components/login-form.tsx | 121 +++-- src/modules/auth/components/register-form.tsx | 118 +++-- src/modules/auth/data-access.ts | 122 +++++ src/modules/auth/schema.ts | 130 +++++ src/modules/auth/services/login-service.ts | 213 ++++++++ .../auth/services/two-factor-service.ts | 120 +++++ src/modules/auth/types.ts | 45 ++ src/modules/files/actions.ts | 310 ++++++++++++ .../files/components/admin-files-view.tsx | 404 ++++++++------- src/modules/files/components/file-list.tsx | 126 ----- .../files/components/file-preview-dialog.tsx | 20 +- src/modules/files/components/file-preview.tsx | 161 +++--- src/modules/files/components/file-upload.tsx | 185 ++----- .../files/hooks/use-file-batch-operations.ts | 132 +++++ src/modules/files/hooks/use-file-preview.ts | 64 +++ src/modules/files/hooks/use-file-upload.ts | 181 +++++++ src/modules/files/schema.ts | 84 ++++ src/modules/files/types.ts | 11 +- src/modules/notifications/actions.ts | 85 +++- .../notifications/channels/sms-channel.ts | 4 +- .../components/notification-dropdown.tsx | 3 +- .../components/notification-list.tsx | 7 +- src/modules/notifications/data-access.ts | 4 +- src/modules/notifications/dispatcher.ts | 10 +- src/modules/notifications/index.ts | 4 + src/modules/notifications/schema.test.ts | 142 ++++++ src/modules/notifications/schema.ts | 25 + src/modules/notifications/types.ts | 2 +- src/modules/onboarding/actions.ts | 41 +- .../components/onboarding-stepper.tsx | 313 +++--------- .../components/parent-children-form.tsx | 122 +++++ src/modules/onboarding/data-access.ts | 14 +- .../onboarding/hooks/use-onboarding-form.ts | 220 +++++++++ src/modules/onboarding/types.ts | 86 +++- .../parent/components/child-detail-panel.tsx | 80 +-- .../parent/components/child-exam-detail.tsx | 96 ++-- .../components/parent-attendance-calendar.tsx | 7 +- .../parent-attendance-rate-card.tsx | 12 +- .../components/parent-attendance-warning.tsx | 19 +- .../parent/components/parent-dashboard.tsx | 107 ---- .../parent-student-attendance-detail.tsx | 163 ++++++ src/modules/parent/data-access.ts | 23 +- src/modules/parent/types.ts | 2 + 84 files changed, 6060 insertions(+), 2530 deletions(-) create mode 100644 src/modules/adaptive-practice/components/answer-input.tsx create mode 100644 src/modules/adaptive-practice/components/answer-result.tsx create mode 100644 src/modules/adaptive-practice/components/practice-result-view.tsx create mode 100644 src/modules/adaptive-practice/components/practice-starter-with-nav.tsx create mode 100644 src/modules/adaptive-practice/components/question-card.tsx create mode 100644 src/modules/adaptive-practice/components/question-content.tsx create mode 100644 src/modules/adaptive-practice/lib/answer-utils.ts create mode 100644 src/modules/adaptive-practice/lib/errors.ts create mode 100644 src/modules/adaptive-practice/lib/grading.ts create mode 100644 src/modules/adaptive-practice/lib/source-meta.ts create mode 100644 src/modules/adaptive-practice/lib/type-guards.ts create mode 100644 src/modules/adaptive-practice/services/practice-service.tsx delete mode 100644 src/modules/ai/components/ai-suggestion-card.tsx create mode 100644 src/modules/ai/context/create-ai-client-service.ts delete mode 100644 src/modules/ai/hooks/use-ai-chat.ts create mode 100644 src/modules/ai/hooks/use-drag-position.ts create mode 100644 src/modules/ai/hooks/use-position-persistence.ts create mode 100644 src/modules/auth/actions.ts create mode 100644 src/modules/auth/data-access.ts create mode 100644 src/modules/auth/schema.ts create mode 100644 src/modules/auth/services/login-service.ts create mode 100644 src/modules/auth/services/two-factor-service.ts create mode 100644 src/modules/auth/types.ts create mode 100644 src/modules/files/actions.ts delete mode 100644 src/modules/files/components/file-list.tsx create mode 100644 src/modules/files/hooks/use-file-batch-operations.ts create mode 100644 src/modules/files/hooks/use-file-preview.ts create mode 100644 src/modules/files/hooks/use-file-upload.ts create mode 100644 src/modules/files/schema.ts create mode 100644 src/modules/notifications/schema.test.ts create mode 100644 src/modules/notifications/schema.ts create mode 100644 src/modules/onboarding/components/parent-children-form.tsx create mode 100644 src/modules/onboarding/hooks/use-onboarding-form.ts delete mode 100644 src/modules/parent/components/parent-dashboard.tsx create mode 100644 src/modules/parent/components/parent-student-attendance-detail.tsx diff --git a/src/modules/adaptive-practice/actions.ts b/src/modules/adaptive-practice/actions.ts index c3c5124..09b3c5a 100644 --- a/src/modules/adaptive-practice/actions.ts +++ b/src/modules/adaptive-practice/actions.ts @@ -3,9 +3,14 @@ import { revalidatePath } from "next/cache" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" -import { Permissions } from "@/shared/types/permissions" +import { Permissions, type AuthContext } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" import { handleActionError } from "@/shared/lib/action-utils" +import { + getStudentActiveClassId, + getStudentActiveGradeId, + verifyTeacherOwnsClass, +} from "@/modules/classes/data-access" import { CreatePracticeSessionSchema, @@ -22,7 +27,98 @@ import { getPracticeSessions, getPracticeStats, } from "./data-access" -import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types" +import { parseSourceMeta } from "./lib/source-meta" +import { practiceErrors } from "./lib/errors" +import type { + PracticeSessionDetail, + PracticeSessionSummary, + PracticeStats, +} from "./types" + +// --------------------------------------------------------------------------- +// 权限辅助:dataScope 下 studentId 归属校验 +// --------------------------------------------------------------------------- + +/** + * 根据 dataScope 解析并校验目标学生 ID。 + * + * 行级权限校验(P0 安全修复): + * - `all`:admin 可访问任意 studentId(不传则用 ctx.userId) + * - `owned`:仅可访问 ctx.userId(学生本人) + * - `class_members`:仅可访问 ctx.userId(学生本人) + * - `children`:仅可访问 childrenIds 中的学生(家长) + * - `class_taught`:校验学生属于教师所教班级(教师) + * - `grade_managed`:校验学生属于年级主任所辖年级(年级主任) + * + * @throws {PermissionDeniedError} studentId 不在 dataScope 范围内 + * @returns 校验通过的目标 studentId + */ +async function resolveTargetStudentId( + ctx: AuthContext, + studentId?: string, +): Promise { + // 未传 studentId:根据 scope 选取默认值 + if (!studentId || studentId === ctx.userId) { + if (ctx.dataScope.type === "children") { + // 家长默认查第一个孩子 + return ctx.dataScope.childrenIds[0] ?? ctx.userId + } + return ctx.userId + } + + // 传入了 studentId:按 scope 校验归属 + switch (ctx.dataScope.type) { + case "all": + // admin 无限制 + return studentId + + case "owned": + // 学生仅能访问自己 + if (studentId !== ctx.userId) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + return studentId + + case "class_members": + // 学生仅能访问自己 + if (studentId !== ctx.userId) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + return studentId + + case "children": + // 家长仅能访问自己的子女 + if (!ctx.dataScope.childrenIds.includes(studentId)) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + return studentId + + case "class_taught": { + // 教师:校验学生属于其所教班级 + const classId = await getStudentActiveClassId(studentId) + if (!classId) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + const owns = await verifyTeacherOwnsClass(classId, ctx.userId) + if (!owns) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + return studentId + } + + case "grade_managed": { + // 年级主任:校验学生属于其所辖年级 + const gradeId = await getStudentActiveGradeId(studentId) + if (!gradeId || !ctx.dataScope.gradeIds.includes(gradeId)) { + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } + return studentId + } + + default: + throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) + } +} // --------------------------------------------------------------------------- // 查询 Actions @@ -33,25 +129,12 @@ export async function getPracticeSessionsAction( ): Promise> { try { const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) - - let targetStudentId = ctx.userId - if (studentId && studentId !== ctx.userId) { - if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { - throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) - } - targetStudentId = studentId - } else if (ctx.dataScope.type === "children") { - targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId - } + const targetStudentId = await resolveTargetStudentId(ctx, studentId) const result = await getPracticeSessions(targetStudentId) return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - const message = e instanceof Error ? e.message : "获取练习列表失败" - return { success: false, message } + return handleActionError(e) } } @@ -61,28 +144,16 @@ export async function getPracticeSessionDetailAction( ): Promise> { try { const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) - - let targetStudentId = ctx.userId - if (studentId && studentId !== ctx.userId) { - if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { - throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) - } - targetStudentId = studentId - } else if (ctx.dataScope.type === "children") { - targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId - } + const targetStudentId = await resolveTargetStudentId(ctx, studentId) const data = await getPracticeSessionById(sessionId, targetStudentId) if (!data) { - return { success: false, message: "练习会话不存在或无权访问" } + // 通过 errorCode 返回,前端 t(`errors.session_not_found`) 查 i18n + throw practiceErrors.sessionNotFound() } return { success: true, data } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - const message = e instanceof Error ? e.message : "获取练习详情失败" - return { success: false, message } + return handleActionError(e) } } @@ -91,25 +162,12 @@ export async function getPracticeStatsAction( ): Promise> { try { const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) - - let targetStudentId = ctx.userId - if (studentId && studentId !== ctx.userId) { - if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) { - throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ) - } - targetStudentId = studentId - } else if (ctx.dataScope.type === "children") { - targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId - } + const targetStudentId = await resolveTargetStudentId(ctx, studentId) const data = await getPracticeStats(targetStudentId) return { success: true, data } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - const message = e instanceof Error ? e.message : "获取练习统计失败" - return { success: false, message } + return handleActionError(e) } } @@ -126,7 +184,8 @@ export async function createPracticeSessionAction( const jsonString = formData.get("json") if (typeof jsonString !== "string") { - return { success: false, message: "提交格式错误,需要 JSON 字段" } + // errorCode: invalid_input → 前端 t(`errors.invalid_input`) + return { success: false, message: "提交格式错误", errorCode: "invalid_input" } } const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString)) @@ -134,31 +193,38 @@ export async function createPracticeSessionAction( return { success: false, message: "输入验证失败", + errorCode: "validation_error", errors: parsed.error.flatten().fieldErrors, } } - // 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换 - // 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record - // 而实际运行时结构由前端按练习类型构建,此处做类型收窄 - const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta + // 严格类型守卫:替代 `as unknown as PracticeSourceMeta` 断言 + const sourceMeta = parseSourceMeta(parsed.data.practiceType, parsed.data.sourceMeta) + if (!sourceMeta) { + return { + success: false, + message: "来源元数据结构与练习类型不匹配", + errorCode: "invalid_source_meta", + } + } - const result = await createPracticeSession(ctx.userId, { + // 写入操作仅允许学生本人或家长代子女发起 + // 家长 dataScope.type === "children" 时,使用第一个子女 ID + const targetStudentId = ctx.dataScope.type === "children" + ? ctx.dataScope.childrenIds[0] ?? ctx.userId + : ctx.userId + + const result = await createPracticeSession(targetStudentId, { practiceType: parsed.data.practiceType, subjectId: parsed.data.subjectId, sourceMeta, questionCount: parsed.data.questionCount, }) - if (result.selectedCount === 0) { - return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" } - } - revalidatePath("/student/practice") return { success: true, - message: `已创建练习会话,共 ${result.selectedCount} 道题目`, data: result, } } catch (e) { @@ -175,7 +241,7 @@ export async function submitPracticeAnswerAction( const jsonString = formData.get("json") if (typeof jsonString !== "string") { - return { success: false, message: "提交格式错误" } + return { success: false, message: "提交格式错误", errorCode: "invalid_input" } } const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString)) @@ -183,15 +249,21 @@ export async function submitPracticeAnswerAction( return { success: false, message: "输入验证失败", + errorCode: "validation_error", errors: parsed.error.flatten().fieldErrors, } } const { sessionId, answerId, answer, skip } = parsed.data + // 学生本人或家长代子女提交 + const targetStudentId = ctx.dataScope.type === "children" + ? ctx.dataScope.childrenIds[0] ?? ctx.userId + : ctx.userId + const result = await submitPracticeAnswer( sessionId, - ctx.userId, + targetStudentId, answerId, answer, skip ?? false, @@ -201,7 +273,6 @@ export async function submitPracticeAnswerAction( return { success: true, - message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"), data: result, } } catch (e) { @@ -223,16 +294,21 @@ export async function completePracticeSessionAction( return { success: false, message: "输入验证失败", + errorCode: "validation_error", errors: parsed.error.flatten().fieldErrors, } } - await completePracticeSession(parsed.data.sessionId, ctx.userId) + const targetStudentId = ctx.dataScope.type === "children" + ? ctx.dataScope.childrenIds[0] ?? ctx.userId + : ctx.userId + + await completePracticeSession(parsed.data.sessionId, targetStudentId) revalidatePath("/student/practice") revalidatePath(`/student/practice/${parsed.data.sessionId}`) - return { success: true, message: "练习已完成" } + return { success: true } } catch (e) { return handleActionError(e) } @@ -252,15 +328,20 @@ export async function abandonPracticeSessionAction( return { success: false, message: "输入验证失败", + errorCode: "validation_error", errors: parsed.error.flatten().fieldErrors, } } - await abandonPracticeSession(parsed.data.sessionId, ctx.userId) + const targetStudentId = ctx.dataScope.type === "children" + ? ctx.dataScope.childrenIds[0] ?? ctx.userId + : ctx.userId + + await abandonPracticeSession(parsed.data.sessionId, targetStudentId) revalidatePath("/student/practice") - return { success: true, message: "练习已放弃" } + return { success: true } } catch (e) { return handleActionError(e) } diff --git a/src/modules/adaptive-practice/components/answer-input.tsx b/src/modules/adaptive-practice/components/answer-input.tsx new file mode 100644 index 0000000..b726d5a --- /dev/null +++ b/src/modules/adaptive-practice/components/answer-input.tsx @@ -0,0 +1,145 @@ +"use client" + +import { useTranslations } from "next-intl" + +import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group" +import { Label } from "@/shared/components/ui/label" +import { Checkbox } from "@/shared/components/ui/checkbox" + +import { extractOptions } from "../lib/grading" +import { isStringArray } from "../lib/answer-utils" + +interface AnswerInputProps { + questionType: string + content: unknown + userAnswer: unknown + onAnswerChange: (answer: unknown) => void + /** 是否禁用输入 */ + disabled?: boolean +} + +/** + * 答题输入组件。 + * + * 根据题目类型渲染不同的输入控件: + * - single_choice: 单选 RadioGroup + * - multiple_choice: 多选 Checkbox 组 + * - judgment: 判断 RadioGroup(正确/错误) + * - text/其他: 文本域 + * + * 所有控件均带 aria-label 供屏幕阅读器识别。 + */ +export function AnswerInput({ + questionType, + content, + userAnswer, + onAnswerChange, + disabled = false, +}: AnswerInputProps): React.ReactNode { + const t = useTranslations("practice") + + if (questionType === "single_choice") { + const options = extractOptions(content) + const selectedId = typeof userAnswer === "string" ? userAnswer : "" + + return ( + +
+ {options.map((opt) => ( +
+ + +
+ ))} +
+
+ ) + } + + if (questionType === "multiple_choice") { + const options = extractOptions(content) + const selectedIds = isStringArray(userAnswer) ? userAnswer : [] + + function toggle(id: string): void { + const newIds = selectedIds.includes(id) + ? selectedIds.filter((v) => v !== id) + : [...selectedIds, id] + onAnswerChange(newIds) + } + + return ( +
+ {options.map((opt) => ( +
+ toggle(opt.id)} + id={opt.id} + disabled={disabled} + aria-label={opt.text} + /> + +
+ ))} +
+ ) + } + + if (questionType === "judgment") { + const value = typeof userAnswer === "string" ? userAnswer : "" + + return ( + +
+
+ + +
+
+ + +
+
+
+ ) + } + + // text 题型 + const textValue = typeof userAnswer === "string" ? userAnswer : "" + return ( +