From ebaf03107d5f0258f516491d85bd0514f74a4d53 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:22:07 +0800 Subject: [PATCH] feat(modules-admin): update scheduling, school, settings, standards, student, textbooks, users - scheduling: update auto-schedule-panel, schedule-change-form, schedule-change-list, schedule-conflicts-view, scheduling-rules-form, data-access-class-schedule, data-access - school: update academic-year-view, departments-view, grade-form-dialog, data-access - settings: update admin-settings-view, ai-provider-settings-card, avatar-upload, brand-config-card, notification-preferences-form, password-change-form, profile-settings-form, security-recent-logins-section, security-two-factor-section - standards: update data-access - student: update student-courses-view - textbooks: update chapter-sidebar-list, create-chapter-dialog, force-graph, graph-kp-node, graph-prerequisite-edge, knowledge-graph-node, textbook-card, textbook-form-dialog, textbook-reader, textbook-settings-dialog, data-access-graph, data-access, use-kp-create, use-kp-delete, use-kp-update, types - users: update user-import-dialog --- .../components/auto-schedule-panel.tsx | 16 +- .../components/schedule-change-form.tsx | 12 +- .../components/schedule-change-list.tsx | 14 +- .../components/schedule-conflicts-view.tsx | 12 +- .../components/scheduling-rules-form.tsx | 8 +- .../scheduling/data-access-class-schedule.ts | 89 +- src/modules/scheduling/data-access.ts | 117 +- .../school/components/academic-year-view.tsx | 20 +- .../school/components/departments-view.tsx | 20 +- .../school/components/grade-form-dialog.tsx | 6 +- src/modules/school/data-access.ts | 995 ++---------------- .../components/admin-settings-view.tsx | 8 +- .../components/ai-provider-settings-card.tsx | 26 +- .../settings/components/avatar-upload.tsx | 23 +- .../settings/components/brand-config-card.tsx | 10 +- .../notification-preferences-form.tsx | 14 +- .../components/password-change-form.tsx | 6 +- .../components/profile-settings-form.tsx | 8 +- .../security-recent-logins-section.tsx | 10 +- .../security-two-factor-section.tsx | 24 +- src/modules/standards/data-access.ts | 3 +- .../components/student-courses-view.tsx | 8 +- .../components/chapter-sidebar-list.tsx | 16 +- .../components/create-chapter-dialog.tsx | 8 +- .../textbooks/components/force-graph.tsx | 50 +- .../textbooks/components/graph-kp-node.tsx | 17 +- .../components/graph-prerequisite-edge.tsx | 6 +- .../components/knowledge-graph-node.tsx | 10 +- .../textbooks/components/textbook-card.tsx | 8 +- .../components/textbook-form-dialog.tsx | 8 +- .../textbooks/components/textbook-reader.tsx | 8 +- .../components/textbook-settings-dialog.tsx | 14 +- src/modules/textbooks/data-access-graph.ts | 91 +- src/modules/textbooks/data-access.ts | 237 ++++- src/modules/textbooks/hooks/use-kp-create.ts | 8 +- src/modules/textbooks/hooks/use-kp-delete.ts | 8 +- src/modules/textbooks/hooks/use-kp-update.ts | 8 +- src/modules/textbooks/types.ts | 26 +- .../users/components/user-import-dialog.tsx | 22 +- 39 files changed, 655 insertions(+), 1339 deletions(-) diff --git a/src/modules/scheduling/components/auto-schedule-panel.tsx b/src/modules/scheduling/components/auto-schedule-panel.tsx index dffba13..c263399 100644 --- a/src/modules/scheduling/components/auto-schedule-panel.tsx +++ b/src/modules/scheduling/components/auto-schedule-panel.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -30,7 +30,7 @@ export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) { const handlePreview = async () => { if (!classId) { - toast.error("Please select a class") + notify.error("Please select a class") return } setLoading(true) @@ -40,12 +40,12 @@ export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) { const res = await autoScheduleAction(null, formData) if (res.success && res.data) { setResult(res.data) - toast.success(res.message) + notify.success(res.message) } else { - toast.error(res.message || "Failed to generate schedule") + notify.error(res.message || "Failed to generate schedule") } } catch { - toast.error("Failed to generate schedule") + notify.error("Failed to generate schedule") } finally { setLoading(false) } @@ -66,13 +66,13 @@ export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) { })) ) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() } else { - toast.error(res.message || "Failed to apply schedule") + notify.error(res.message || "Failed to apply schedule") } } catch { - toast.error("Failed to apply schedule") + notify.error("Failed to apply schedule") } finally { setApplying(false) } diff --git a/src/modules/scheduling/components/schedule-change-form.tsx b/src/modules/scheduling/components/schedule-change-form.tsx index 525fce6..d823c5c 100644 --- a/src/modules/scheduling/components/schedule-change-form.tsx +++ b/src/modules/scheduling/components/schedule-change-form.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -44,11 +44,11 @@ export function ScheduleChangeForm({ const handleSubmit = async (formData: FormData) => { if (!classId) { - toast.error("Please select a class") + notify.error("Please select a class") return } if (!reason.trim()) { - toast.error("Reason is required") + notify.error("Reason is required") return } setSubmitting(true) @@ -64,7 +64,7 @@ export function ScheduleChangeForm({ const res = await requestScheduleChangeAction(null, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() // Reset form setOriginalDate("") @@ -75,10 +75,10 @@ export function ScheduleChangeForm({ setOriginalTeacherId("") setSubstituteTeacherId("") } else { - toast.error(res.message || "Failed to submit request") + notify.error(res.message || "Failed to submit request") } } catch { - toast.error("Failed to submit request") + notify.error("Failed to submit request") } finally { setSubmitting(false) } diff --git a/src/modules/scheduling/components/schedule-change-list.tsx b/src/modules/scheduling/components/schedule-change-list.tsx index 71c05a0..879a6a2 100644 --- a/src/modules/scheduling/components/schedule-change-list.tsx +++ b/src/modules/scheduling/components/schedule-change-list.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { Check, X } from "lucide-react" @@ -52,14 +52,14 @@ export function ScheduleChangeList({ items, canApprove = false }: ScheduleChange try { const res = await approveScheduleChangeAction(approveId) if (res.success) { - toast.success(res.message) + notify.success(res.message) setApproveId(null) router.refresh() } else { - toast.error(res.message || "Failed to approve") + notify.error(res.message || "Failed to approve") } } catch { - toast.error("Failed to approve") + notify.error("Failed to approve") } finally { setActing(false) } @@ -71,15 +71,15 @@ export function ScheduleChangeList({ items, canApprove = false }: ScheduleChange try { const res = await rejectScheduleChangeAction(rejectId, rejectReason || undefined) if (res.success) { - toast.success(res.message) + notify.success(res.message) setRejectId(null) setRejectReason("") router.refresh() } else { - toast.error(res.message || "Failed to reject") + notify.error(res.message || "Failed to reject") } } catch { - toast.error("Failed to reject") + notify.error("Failed to reject") } finally { setActing(false) } diff --git a/src/modules/scheduling/components/schedule-conflicts-view.tsx b/src/modules/scheduling/components/schedule-conflicts-view.tsx index 33525ae..e110060 100644 --- a/src/modules/scheduling/components/schedule-conflicts-view.tsx +++ b/src/modules/scheduling/components/schedule-conflicts-view.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { AlertTriangle, CheckCircle2, RefreshCw } from "lucide-react" @@ -36,7 +36,7 @@ export function ScheduleConflictsView({ classes }: { classes: ClassOption[] }) { const handleCheck = async () => { if (!classId) { - toast.error("Please select a class") + notify.error("Please select a class") return } setLoading(true) @@ -45,15 +45,15 @@ export function ScheduleConflictsView({ classes }: { classes: ClassOption[] }) { if (res.success && res.data) { setConflicts(res.data) if (res.data.length === 0) { - toast.success("No conflicts detected") + notify.success("No conflicts detected") } else { - toast.warning(`Found ${res.data.length} conflict(s)`) + notify.warning(`Found ${res.data.length} conflict(s)`) } } else { - toast.error(res.message || "Failed to check conflicts") + notify.error(res.message || "Failed to check conflicts") } } catch { - toast.error("Failed to check conflicts") + notify.error("Failed to check conflicts") } finally { setLoading(false) } diff --git a/src/modules/scheduling/components/scheduling-rules-form.tsx b/src/modules/scheduling/components/scheduling-rules-form.tsx index 27d1ee3..925ae94 100644 --- a/src/modules/scheduling/components/scheduling-rules-form.tsx +++ b/src/modules/scheduling/components/scheduling-rules-form.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useFormStatus } from "react-dom" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -88,7 +88,7 @@ export function SchedulingRulesForm({ const handleSubmit = async (formData: FormData) => { if (!classId) { - toast.error("Please select a class") + notify.error("Please select a class") return } formData.set("classId", classId) @@ -103,10 +103,10 @@ export function SchedulingRulesForm({ const result = await saveSchedulingRulesAction(null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) router.refresh() } else { - toast.error(result.message || "Failed to save rules") + notify.error(result.message || "Failed to save rules") } } diff --git a/src/modules/scheduling/data-access-class-schedule.ts b/src/modules/scheduling/data-access-class-schedule.ts index 715e62c..a8d65b3 100644 --- a/src/modules/scheduling/data-access-class-schedule.ts +++ b/src/modules/scheduling/data-access-class-schedule.ts @@ -13,46 +13,40 @@ import { updateClassScheduleItemById, deleteClassScheduleItemById, } from "./data-access" +import { + buildValidatedScheduleUpdate, + normalizeAndValidateCreateInput, +} from "./lib/schedule-validation" import type { CreateClassScheduleItemInput, UpdateClassScheduleItemInput, } from "./types" -const isTimeHHMM = (v: string): boolean => /^\d{2}:\d{2}$/.test(v) - /** * Create a single classSchedule item. * Ownership: the caller (teacher) must own the target class. * DB write is delegated to the unified scheduling write entry point. + * + * A-02 修复:字段校验(时间格式/星期范围/时间区间)已抽离至 + * lib/schedule-validation.ts,本函数仅保留 DB 相关的归属校验 + CRUD。 */ export async function createClassScheduleItem( data: CreateClassScheduleItemInput, ): Promise { const teacherId = await getTeacherIdForMutations() - const classId = data.classId.trim() - const course = data.course.trim() - const startTime = data.startTime.trim() - const endTime = data.endTime.trim() - const location = data.location?.trim() || null - const weekday = data.weekday + const normalized = normalizeAndValidateCreateInput(data) - if (!classId) throw new Error("Class is required") - if (!course) throw new Error("Course is required") - if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format") - if (startTime >= endTime) throw new Error("Start time must be earlier than end time") - if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday") - - const owned = await verifyTeacherOwnsClass(classId, teacherId) + const owned = await verifyTeacherOwnsClass(normalized.classId, teacherId) if (!owned) throw new Error("Class not found") return insertClassScheduleItem({ - classId, - weekday, - startTime, - endTime, - course, - location, + classId: normalized.classId, + weekday: normalized.weekday, + startTime: normalized.startTime, + endTime: normalized.endTime, + course: normalized.course, + location: normalized.location, }) } @@ -60,6 +54,9 @@ export async function createClassScheduleItem( * Update a classSchedule item by id. * Ownership: the teacher must own the class associated with the schedule item * (and the target class when classId is being changed). + * + * A-02 修复:字段校验与 update 对象构建已抽离至 lib/schedule-validation.ts, + * 本函数仅保留 DB 读取(existing 记录)、归属校验(DB 相关)+ CRUD 写入。 */ export async function updateClassScheduleItem( scheduleId: string, @@ -85,49 +82,19 @@ export async function updateClassScheduleItem( const ownedExisting = await verifyTeacherOwnsClass(existing.classId, teacherId) if (!ownedExisting) throw new Error("Schedule item not found") - const update: Partial = {} - - if (typeof data.classId === "string") { - const nextClassId = data.classId.trim() - if (!nextClassId) throw new Error("Class is required") + const { update, nextClassId } = buildValidatedScheduleUpdate( + { + classId: existing.classId, + startTime: existing.startTime, + endTime: existing.endTime, + }, + data, + ) + // classId 变更时需校验目标班级归属(DB 相关校验,保留在 data-access) + if (nextClassId !== undefined) { const ownedNext = await verifyTeacherOwnsClass(nextClassId, teacherId) if (!ownedNext) throw new Error("Class not found") - update.classId = nextClassId - } - - if (typeof data.weekday === "number") { - if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday") - update.weekday = data.weekday - } - - if (typeof data.course === "string") { - const course = data.course.trim() - if (!course) throw new Error("Course is required") - update.course = course - } - - const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined - const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined - if (nextStart !== undefined) { - if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format") - update.startTime = nextStart - } - if (nextEnd !== undefined) { - if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format") - update.endTime = nextEnd - } - - if (update.startTime !== undefined || update.endTime !== undefined) { - const mergedStart = update.startTime ?? existing.startTime - const mergedEnd = update.endTime ?? existing.endTime - if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) { - throw new Error("Start time must be earlier than end time") - } - } - - if (data.location !== undefined) { - update.location = data.location?.trim() || null } if (Object.keys(update).length === 0) return diff --git a/src/modules/scheduling/data-access.ts b/src/modules/scheduling/data-access.ts index cbd4494..162d89a 100644 --- a/src/modules/scheduling/data-access.ts +++ b/src/modules/scheduling/data-access.ts @@ -1,20 +1,23 @@ import "server-only" -import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm" +import { and, asc, desc, eq, isNull, or, type SQL } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" import { db } from "@/shared/db" import { cacheFn } from "@/shared/lib/cache" import { - classes, classSchedule, - classSubjectTeachers, - classrooms, scheduleChanges, schedulingRules, - subjects, - users, } from "@/shared/db/schema" +import { + getClassNamesByIds, + getClassesForScheduling, + getClassSubjectAssignmentsByClassId, + getDistinctTeacherIdsFromAssignments, +} from "@/modules/classes/data-access" +import { getUserNamesByIds, getTeachersByIds } from "@/modules/users/data-access" +import { getClassroomsForScheduling as getSchoolClassroomsForScheduling, getSubjectNameMapByIds } from "@/modules/school/data-access" import type { ScheduleChangeListItem, @@ -50,7 +53,20 @@ export async function getSchedulingRulesRaw(classId?: string): Promise 0 ? and(...conditions) : undefined) .orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt)) @@ -66,7 +82,7 @@ export const getSchedulingRules = cacheFn(getSchedulingRulesRaw, { export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise { const [existing] = await db - .select() + .select({ id: schedulingRules.id }) .from(schedulingRules) .where(eq(schedulingRules.classId, data.classId)) .limit(1) @@ -116,34 +132,30 @@ export async function getScheduleChangesRaw( const rows = await db .select({ change: scheduleChanges, - className: classes.name, - originalTeacherName: users.name, }) .from(scheduleChanges) - .innerJoin(classes, eq(classes.id, scheduleChanges.classId)) - .leftJoin(users, eq(users.id, scheduleChanges.originalTeacherId)) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(scheduleChanges.createdAt)) - // Resolve substitute teacher & approver names separately to avoid join ambiguity + if (rows.length === 0) return [] + + // 批量解析 className 与 user 名称,避免直接 JOIN classes/users 表 + const classIds = Array.from(new Set(rows.map((r) => r.change.classId))) const userIds = Array.from( new Set( rows.flatMap((r) => [ r.change.substituteTeacherId, r.change.approvedBy, r.change.requestedBy, + r.change.originalTeacherId, ].filter((v): v is string => typeof v === "string" && v.length > 0)) ) ) - const userMap = new Map() - if (userIds.length > 0) { - const userRows = await db - .select({ id: users.id, name: users.name }) - .from(users) - .where(inArray(users.id, userIds)) - for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown") - } + const [classNameMap, userMap] = await Promise.all([ + getClassNamesByIds(classIds), + getUserNamesByIds(userIds), + ]) return rows.map((r) => ({ id: r.change.id, @@ -161,13 +173,15 @@ export async function getScheduleChangesRaw( approvedBy: r.change.approvedBy ?? null, createdAt: r.change.createdAt.toISOString(), updatedAt: r.change.updatedAt.toISOString(), - className: r.className, - originalTeacherName: r.originalTeacherName ?? null, - substituteTeacherName: r.change.substituteTeacherId - ? userMap.get(r.change.substituteTeacherId) ?? null + className: classNameMap.get(r.change.classId) ?? "Unknown", + originalTeacherName: r.change.originalTeacherId + ? userMap.get(r.change.originalTeacherId)?.name ?? null : null, - requesterName: userMap.get(r.change.requestedBy) ?? "Unknown", - approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy) ?? null : null, + substituteTeacherName: r.change.substituteTeacherId + ? userMap.get(r.change.substituteTeacherId)?.name ?? null + : null, + requesterName: userMap.get(r.change.requestedBy)?.name ?? "Unknown", + approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy)?.name ?? null : null, })) } @@ -280,10 +294,8 @@ export const getClassConflicts = cacheFn(getClassConflictsRaw, { }) export async function getAdminClassesForSchedulingRaw(): Promise { - return await db - .select({ id: classes.id, name: classes.name, grade: classes.grade }) - .from(classes) - .orderBy(classes.grade, classes.name) + // 通过 classes 模块 data-access 获取班级列表,避免直接查询 classes 表 + return await getClassesForScheduling() } export const getAdminClassesForScheduling = cacheFn(getAdminClassesForSchedulingRaw, { @@ -293,12 +305,14 @@ export const getAdminClassesForScheduling = cacheFn(getAdminClassesForScheduling }) export async function getTeachersForSchedulingRaw(): Promise { - return await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .innerJoin(classSubjectTeachers, eq(classSubjectTeachers.teacherId, users.id)) - .groupBy(users.id, users.name, users.email) - .orderBy(users.name) + // 通过 classes 模块获取有任课的教师 ID,再通过 users 模块解析教师详情, + // 避免直接查询 users / classSubjectTeachers 表 + const teacherIds = await getDistinctTeacherIdsFromAssignments() + if (teacherIds.length === 0) return [] + const teachers = await getTeachersByIds(teacherIds) + return teachers + .map((t) => ({ id: t.id, name: t.name, email: t.email })) + .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) } export const getTeachersForScheduling = cacheFn(getTeachersForSchedulingRaw, { @@ -308,10 +322,9 @@ export const getTeachersForScheduling = cacheFn(getTeachersForSchedulingRaw, { }) export async function getClassroomsForSchedulingRaw(): Promise { - return await db - .select({ id: classrooms.id, name: classrooms.name, building: classrooms.building }) - .from(classrooms) - .orderBy(classrooms.name) + // 通过 school 模块 data-access 获取教室列表,避免直接查询 classrooms 表 + const rooms = await getSchoolClassroomsForScheduling() + return rooms.map((c) => ({ id: c.id, name: c.name, building: c.building })) } export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw, { @@ -323,15 +336,19 @@ export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw, export async function getClassSubjectsForSchedulingRaw( classId: string ): Promise { - return await db - .select({ - subjectId: subjects.id, - subjectName: subjects.name, - teacherId: classSubjectTeachers.teacherId, - }) - .from(classSubjectTeachers) - .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId)) - .where(eq(classSubjectTeachers.classId, classId)) + // 通过 classes 模块获取班级科目分配,再通过 school 模块解析科目名称, + // 避免直接查询 classSubjectTeachers / subjects 表 + const assignments = await getClassSubjectAssignmentsByClassId(classId) + if (assignments.length === 0) return [] + + const subjectIds = Array.from(new Set(assignments.map((a) => a.subjectId))) + const subjectNameMap = await getSubjectNameMapByIds(subjectIds) + + return assignments.map((a) => ({ + subjectId: a.subjectId, + subjectName: subjectNameMap.get(a.subjectId) ?? "Unknown", + teacherId: a.teacherId, + })) } export const getClassSubjectsForScheduling = cacheFn(getClassSubjectsForSchedulingRaw, { diff --git a/src/modules/school/components/academic-year-view.tsx b/src/modules/school/components/academic-year-view.tsx index a401a10..a11b8d5 100644 --- a/src/modules/school/components/academic-year-view.tsx +++ b/src/modules/school/components/academic-year-view.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react" import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -56,14 +56,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) formData.set("isActive", createActive ? "true" : "false") const res = await createAcademicYearAction(undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) setCreateOpen(false) router.refresh() } else { - toast.error(res.message || t("academicYear.delete.title")) + notify.error(res.message || t("academicYear.delete.title")) } } catch { - toast.error(t("academicYear.delete.title")) + notify.error(t("academicYear.delete.title")) } finally { setIsWorking(false) } @@ -76,14 +76,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) formData.set("isActive", editActive ? "true" : "false") const res = await updateAcademicYearAction(editItem.id, undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) setEditItem(null) router.refresh() } else { - toast.error(res.message || t("academicYear.delete.title")) + notify.error(res.message || t("academicYear.delete.title")) } } catch { - toast.error(t("academicYear.delete.title")) + notify.error(t("academicYear.delete.title")) } finally { setIsWorking(false) } @@ -95,14 +95,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) try { const res = await deleteAcademicYearAction(deleteItem.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) setDeleteItem(null) router.refresh() } else { - toast.error(res.message || t("academicYear.delete.title")) + notify.error(res.message || t("academicYear.delete.title")) } } catch { - toast.error(t("academicYear.delete.title")) + notify.error(t("academicYear.delete.title")) } finally { setIsWorking(false) } diff --git a/src/modules/school/components/departments-view.tsx b/src/modules/school/components/departments-view.tsx index 351c9c4..724884d 100644 --- a/src/modules/school/components/departments-view.tsx +++ b/src/modules/school/components/departments-view.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -49,14 +49,14 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList try { const res = await createDepartmentAction(undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) setCreateOpen(false) router.refresh() } else { - toast.error(res.message || t("departments.delete.title")) + notify.error(res.message || t("departments.delete.title")) } } catch { - toast.error(t("departments.delete.title")) + notify.error(t("departments.delete.title")) } finally { setIsWorking(false) } @@ -68,14 +68,14 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList try { const res = await updateDepartmentAction(editItem.id, undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) setEditItem(null) router.refresh() } else { - toast.error(res.message || t("departments.delete.title")) + notify.error(res.message || t("departments.delete.title")) } } catch { - toast.error(t("departments.delete.title")) + notify.error(t("departments.delete.title")) } finally { setIsWorking(false) } @@ -87,14 +87,14 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList try { const res = await deleteDepartmentAction(deleteItem.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) setDeleteItem(null) router.refresh() } else { - toast.error(res.message || t("departments.delete.title")) + notify.error(res.message || t("departments.delete.title")) } } catch { - toast.error(t("departments.delete.title")) + notify.error(t("departments.delete.title")) } finally { setIsWorking(false) } diff --git a/src/modules/school/components/grade-form-dialog.tsx b/src/modules/school/components/grade-form-dialog.tsx index 5b33a4d..3b9cfd6 100644 --- a/src/modules/school/components/grade-form-dialog.tsx +++ b/src/modules/school/components/grade-form-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useMemo, useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import type { GradeListItem, SchoolListItem, StaffOption } from "../types" @@ -162,11 +162,11 @@ export function GradeFormDialog({ const handleSubmit = (): void => { const result = validateForm(state, editItem?.id) if (!result.ok) { - toast.error(Object.values(result.errors)[0] || t("grades.validation.fixForm")) + notify.error(Object.values(result.errors)[0] || t("grades.validation.fixForm")) return } if (isEdit && !isDirty) { - toast.message(t("grades.validation.noChanges")) + notify.message(t("grades.validation.noChanges")) return } diff --git a/src/modules/school/data-access.ts b/src/modules/school/data-access.ts index 96fe97b..aa43513 100644 --- a/src/modules/school/data-access.ts +++ b/src/modules/school/data-access.ts @@ -1,940 +1,79 @@ import "server-only" -import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm" - -import { cacheFn } from "@/shared/lib/cache" -import { db } from "@/shared/db" -import { createModuleLogger } from "@/shared/lib/logger" -import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema" -import type { - AcademicYearInsertData, - AcademicYearListItem, - AcademicYearUpdateData, - DepartmentInsertData, - DepartmentListItem, - DepartmentUpdateData, - GradeInsertData, - GradeListItem, - GradeUpdateData, - OrgTreeNode, - SchoolInsertData, - SchoolListItem, - SchoolUpdateData, - StaffOption, -} from "./types" - -const log = createModuleLogger("school") -const toIso = (d: Date): string => d.toISOString() - -export const getDepartmentsRaw = async (): Promise => { - try { - const rows = await db.select().from(departments).orderBy(asc(departments.name)) - return rows.map((r) => ({ - id: r.id, - name: r.name, - description: r.description ?? null, - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } catch (error) { - log.error({ err: error }, "getDepartments failed") - return [] - } -} -export const getDepartments = cacheFn(getDepartmentsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getDepartments"], -}) - -export const getAcademicYearsRaw = async (): Promise => { - try { - const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate)) - return rows.map((r) => ({ - id: r.id, - name: r.name, - startDate: toIso(r.startDate), - endDate: toIso(r.endDate), - isActive: Boolean(r.isActive), - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } catch (error) { - log.error({ err: error }, "getAcademicYears failed") - return [] - } -} -export const getAcademicYears = cacheFn(getAcademicYearsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getAcademicYears"], -}) - -export const getSchoolsRaw = async (): Promise => { - try { - const rows = await db.select().from(schools).orderBy(asc(schools.name)) - return rows.map((r) => ({ - id: r.id, - name: r.name, - code: r.code ?? null, - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } catch (error) { - log.error({ err: error }, "getSchools failed") - return [] - } -} -export const getSchools = cacheFn(getSchoolsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getSchools"], -}) - -export const getGradesRaw = async (): Promise => { - try { - const rows = await db - .select({ - id: grades.id, - name: grades.name, - order: grades.order, - schoolId: schools.id, - schoolName: schools.name, - gradeHeadId: grades.gradeHeadId, - teachingHeadId: grades.teachingHeadId, - createdAt: grades.createdAt, - updatedAt: grades.updatedAt, - }) - .from(grades) - .innerJoin(schools, eq(schools.id, grades.schoolId)) - .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) - - const headIds = Array.from( - new Set( - rows - .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) - .filter((v): v is string => typeof v === "string" && v.length > 0) - ) - ) - - const heads = headIds.length - ? await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .where(inArray(users.id, headIds)) - : [] - - const headById = new Map() - for (const u of heads) { - headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) - } - - return rows.map((r) => ({ - id: r.id, - school: { id: r.schoolId, name: r.schoolName }, - name: r.name, - order: Number(r.order ?? 0), - gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, - teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } catch (error) { - log.error({ err: error }, "getGrades failed") - return [] - } -} -export const getGrades = cacheFn(getGradesRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getGrades"], -}) - -export const getStaffOptionsRaw = async (): Promise => { - try { - const rows = await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) - .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) - .where(inArray(roles.name, ["teacher", "admin"])) - .groupBy(users.id, users.name, users.email) - .orderBy(asc(users.name), asc(users.email)) - - return rows.map((r) => ({ - id: r.id, - name: r.name ?? "Unnamed", - email: r.email, - })) - } catch (error) { - log.error({ err: error }, "getStaffOptions failed") - return [] - } -} -export const getStaffOptions = cacheFn(getStaffOptionsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getStaffOptions"], -}) - -export const getGradesForStaffRaw = async (staffId: string): Promise => { - const id = staffId.trim() - if (!id) return [] - - try { - const rows = await db - .select({ - id: grades.id, - name: grades.name, - order: grades.order, - schoolId: schools.id, - schoolName: schools.name, - gradeHeadId: grades.gradeHeadId, - teachingHeadId: grades.teachingHeadId, - createdAt: grades.createdAt, - updatedAt: grades.updatedAt, - }) - .from(grades) - .innerJoin(schools, eq(schools.id, grades.schoolId)) - .where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id))) - .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) - - const headIds = Array.from( - new Set( - rows - .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) - .filter((v): v is string => typeof v === "string" && v.length > 0) - ) - ) - - const heads = headIds.length - ? await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .where(inArray(users.id, headIds)) - : [] - - const headById = new Map() - for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) - - return rows.map((r) => ({ - id: r.id, - school: { id: r.schoolId, name: r.schoolName }, - name: r.name, - order: Number(r.order ?? 0), - gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, - teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } catch (error) { - log.error({ err: error }, "getGradesForStaff failed") - return [] - } -} -export const getGradesForStaff = cacheFn(getGradesForStaffRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getGradesForStaff"], -}) - /** - * 根据用户角色返回可见的学校列表(权限感知)。 - * - admin: 返回全量学校 - * - grade_head / teaching_head: 返回其负责年级所在学校 - * - teacher: 返回其任课班级所在学校 - * - 其他角色: 返回空数组 - */ -export const getSchoolsForUserRaw = async (userId: string): Promise => { - const id = userId.trim() - if (!id) return [] - - try { - const roleRows = await db - .select({ name: roles.name }) - .from(roles) - .innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id)) - .where(eq(usersToRoles.userId, id)) - - const roleNames = new Set(roleRows.map((r) => r.name)) - - if (roleNames.has("admin")) { - return await getSchools() - } - - let schoolIds: string[] = [] - - if (roleNames.has("grade_head") || roleNames.has("teaching_head")) { - const gradeRows = await db - .select({ schoolId: grades.schoolId }) - .from(grades) - .where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id))) - schoolIds = gradeRows.map((r) => r.schoolId) - } else if (roleNames.has("teacher")) { - const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access") - const classIds = await getAccessibleClassIdsForTeacher(id) - if (classIds.length === 0) return [] - - const gradeIds = await getGradeIdsByClassIds(classIds) - if (gradeIds.length === 0) return [] - - const gradeRows = await db - .select({ schoolId: grades.schoolId }) - .from(grades) - .where(inArray(grades.id, gradeIds)) - schoolIds = gradeRows.map((r) => r.schoolId) - } else { - return [] - } - - const uniqueSchoolIds = Array.from( - new Set(schoolIds.filter((v): v is string => typeof v === "string" && v.length > 0)) - ) - if (uniqueSchoolIds.length === 0) return [] - - const rows = await db - .select() - .from(schools) - .where(inArray(schools.id, uniqueSchoolIds)) - .orderBy(asc(schools.name)) - - return rows.map((r) => ({ - id: r.id, - name: r.name, - code: r.code ?? null, - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } catch (error) { - log.error({ err: error }, "getSchoolsForUser failed") - return [] - } -} -export const getSchoolsForUser = cacheFn(getSchoolsForUserRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getSchoolsForUser"], -}) - -/** - * 根据用户角色返回可见的年级列表(权限感知)。 - * - admin: 返回全量年级 - * - grade_head / teaching_head: 返回其负责的年级 - * - teacher: 返回其任课班级所在年级 - * - 其他角色: 返回空数组 - */ -export const getGradesForUserRaw = async (userId: string): Promise => { - const id = userId.trim() - if (!id) return [] - - try { - const roleRows = await db - .select({ name: roles.name }) - .from(roles) - .innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id)) - .where(eq(usersToRoles.userId, id)) - - const roleNames = new Set(roleRows.map((r) => r.name)) - - if (roleNames.has("admin")) { - return await getGrades() - } - - if (roleNames.has("grade_head") || roleNames.has("teaching_head")) { - return await getGradesForStaff(id) - } - - if (roleNames.has("teacher")) { - const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access") - const classIds = await getAccessibleClassIdsForTeacher(id) - if (classIds.length === 0) return [] - - const gradeIds = await getGradeIdsByClassIds(classIds) - if (gradeIds.length === 0) return [] - - const uniqueGradeIds = Array.from(new Set(gradeIds)) - if (uniqueGradeIds.length === 0) return [] - - const rows = await db - .select({ - id: grades.id, - name: grades.name, - order: grades.order, - schoolId: schools.id, - schoolName: schools.name, - gradeHeadId: grades.gradeHeadId, - teachingHeadId: grades.teachingHeadId, - createdAt: grades.createdAt, - updatedAt: grades.updatedAt, - }) - .from(grades) - .innerJoin(schools, eq(schools.id, grades.schoolId)) - .where(inArray(grades.id, uniqueGradeIds)) - .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) - - const headIds = Array.from( - new Set( - rows - .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) - .filter((v): v is string => typeof v === "string" && v.length > 0) - ) - ) - - const heads = headIds.length - ? await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .where(inArray(users.id, headIds)) - : [] - - const headById = new Map() - for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) - - return rows.map((r) => ({ - id: r.id, - school: { id: r.schoolId, name: r.schoolName }, - name: r.name, - order: Number(r.order ?? 0), - gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, - teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, - createdAt: toIso(r.createdAt), - updatedAt: toIso(r.updatedAt), - })) - } - - return [] - } catch (error) { - log.error({ err: error }, "getGradesForUser failed") - return [] - } -} -export const getGradesForUser = cacheFn(getGradesForUserRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getGradesForUser"], -}) - -// --------------------------------------------------------------------------- -// Mutations — DB write operations (called only from actions.ts) -// --------------------------------------------------------------------------- - -export async function createDepartment(data: DepartmentInsertData): Promise { - await db.insert(departments).values({ - id: data.id, - name: data.name, - description: data.description, - }) -} - -export async function updateDepartment( - id: string, - data: DepartmentUpdateData -): Promise { - await db - .update(departments) - .set({ name: data.name, description: data.description }) - .where(eq(departments.id, id)) -} - -export async function deleteDepartment(id: string): Promise { - await db.delete(departments).where(eq(departments.id, id)) -} - -export async function createSchool(data: SchoolInsertData): Promise { - await db.insert(schools).values({ - id: data.id, - name: data.name, - code: data.code, - }) -} - -export async function updateSchool( - id: string, - data: SchoolUpdateData -): Promise { - await db - .update(schools) - .set({ name: data.name, code: data.code }) - .where(eq(schools.id, id)) -} - -export async function deleteSchool(id: string): Promise { - await db.delete(schools).where(eq(schools.id, id)) -} - -export async function createGrade(data: GradeInsertData): Promise { - await db.insert(grades).values({ - id: data.id, - schoolId: data.schoolId, - name: data.name, - order: data.order, - gradeHeadId: data.gradeHeadId, - teachingHeadId: data.teachingHeadId, - }) -} - -export async function updateGrade( - id: string, - data: GradeUpdateData -): Promise { - await db - .update(grades) - .set({ - schoolId: data.schoolId, - name: data.name, - order: data.order, - gradeHeadId: data.gradeHeadId, - teachingHeadId: data.teachingHeadId, - }) - .where(eq(grades.id, id)) -} - -export async function deleteGrade(id: string): Promise { - await db.delete(grades).where(eq(grades.id, id)) -} - -export async function createAcademicYear( - data: AcademicYearInsertData -): Promise { - await db.transaction(async (tx) => { - if (data.isActive) { - await tx.update(academicYears).set({ isActive: false }) - } - await tx.insert(academicYears).values({ - id: data.id, - name: data.name, - startDate: data.startDate, - endDate: data.endDate, - isActive: data.isActive, - }) - }) -} - -export async function updateAcademicYear( - id: string, - data: AcademicYearUpdateData -): Promise { - await db.transaction(async (tx) => { - if (data.isActive) { - await tx.update(academicYears).set({ isActive: false }) - } - await tx - .update(academicYears) - .set({ - name: data.name, - startDate: data.startDate, - endDate: data.endDate, - isActive: data.isActive, - }) - .where(eq(academicYears.id, id)) - }) -} - -export async function deleteAcademicYear(id: string): Promise { - await db.delete(academicYears).where(eq(academicYears.id, id)) -} - -// --------------------------------------------------------------------------- -// Cross-module query interfaces — read-only access for other modules -// --------------------------------------------------------------------------- - -export type SubjectOption = { - id: string - name: string - code: string | null - order: number -} - -export type GradeOption = { - id: string - name: string - schoolId: string - schoolName: string - order: number -} - -export const getSubjectOptionsRaw = async (): Promise => { - try { - const rows = await db - .select({ - id: subjects.id, - name: subjects.name, - code: subjects.code, - order: subjects.order, - }) - .from(subjects) - .orderBy(asc(subjects.order), asc(subjects.name)) - - return rows.map((r) => ({ - id: r.id, - name: r.name, - code: r.code ?? null, - order: Number(r.order ?? 0), - })) - } catch (error) { - log.error({ err: error }, "getSubjectOptions failed") - return [] - } -} -export const getSubjectOptions = cacheFn(getSubjectOptionsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getSubjectOptions"], -}) - -export const getGradeOptionsRaw = async (): Promise => { - try { - const rows = await db - .select({ - id: grades.id, - name: grades.name, - order: grades.order, - schoolId: schools.id, - schoolName: schools.name, - }) - .from(grades) - .innerJoin(schools, eq(schools.id, grades.schoolId)) - .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) - - return rows.map((r) => ({ - id: r.id, - name: r.name, - schoolId: r.schoolId, - schoolName: r.schoolName, - order: Number(r.order ?? 0), - })) - } catch (error) { - log.error({ err: error }, "getGradeOptions failed") - return [] - } -} -export const getGradeOptions = cacheFn(getGradeOptionsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getGradeOptions"], -}) - -/** - * 按 ID 获取单个年级名称。 - * 供跨模块调用使用,避免为单个年级拉取全量年级选项。 - */ -export const getGradeNameByIdRaw = async ( - gradeId: string -): Promise => { - const id = gradeId.trim() - if (!id) return null - const [row] = await db - .select({ name: grades.name }) - .from(grades) - .where(eq(grades.id, id)) - .limit(1) - return row?.name ?? null -} -export const getGradeNameById = cacheFn(getGradeNameByIdRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getGradeNameById"], -}) - -/** - * 按 ID 获取单个科目名称。 - * 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。 - */ -export const getSubjectNameByIdRaw = async ( - subjectId: string -): Promise => { - const id = subjectId.trim() - if (!id) return null - const [row] = await db - .select({ name: subjects.name }) - .from(subjects) - .where(eq(subjects.id, id)) - .limit(1) - return row?.name ?? null -} -export const getSubjectNameById = cacheFn(getSubjectNameByIdRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getSubjectNameById"], -}) - -/** - * 批量获取科目名称映射(subjectId -> name)。 - * 供跨模块调用使用,避免直接查询 subjects 表。 - */ -export const getSubjectNameMapByIdsRaw = async ( - subjectIds: string[] -): Promise> => { - const ids = subjectIds.filter((id) => id.trim().length > 0) - if (ids.length === 0) return new Map() - const rows = await db - .select({ id: subjects.id, name: subjects.name }) - .from(subjects) - .where(inArray(subjects.id, ids)) - const map = new Map() - for (const r of rows) map.set(r.id, r.name) - return map -} -export const getSubjectNameMapByIds = cacheFn(getSubjectNameMapByIdsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getSubjectNameMapByIds"], -}) - -// --------------------------------------------------------------------------- -// Cross-module query interfaces — grade head/teaching head verification -// --------------------------------------------------------------------------- - -/** - * 校验用户是否为指定年级的年级主任。 - * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 - */ -export const isGradeHeadRaw = async ( - gradeId: string, - userId: string -): Promise => { - const trimmedGradeId = gradeId.trim() - const trimmedUserId = userId.trim() - if (!trimmedGradeId || !trimmedUserId) return false - - const [row] = await db - .select({ id: grades.id }) - .from(grades) - .where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId))) - .limit(1) - return Boolean(row) -} -export const isGradeHead = cacheFn(isGradeHeadRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "isGradeHead"], -}) - -/** - * 校验用户是否为指定年级的年级主任或教学主任。 - * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 - */ -export const isGradeManagerRaw = async ( - gradeId: string, - userId: string -): Promise => { - const trimmedGradeId = gradeId.trim() - const trimmedUserId = userId.trim() - if (!trimmedGradeId || !trimmedUserId) return false - - const [row] = await db - .select({ id: grades.id }) - .from(grades) - .where( - and( - eq(grades.id, trimmedGradeId), - or(eq(grades.gradeHeadId, trimmedUserId), eq(grades.teachingHeadId, trimmedUserId)) - ) - ) - .limit(1) - return Boolean(row) -} -export const isGradeManager = cacheFn(isGradeManagerRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "isGradeManager"], -}) - -/** - * 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。 - * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 - */ -export const findGradeIdByHeadAndNameRaw = async ( - userId: string, - gradeName: string -): Promise => { - const trimmedUserId = userId.trim() - const normalizedGradeName = gradeName.trim().toLowerCase() - if (!trimmedUserId || !normalizedGradeName) return null - - const [row] = await db - .select({ id: grades.id }) - .from(grades) - .where( - and( - eq(grades.gradeHeadId, trimmedUserId), - sql`LOWER(${grades.name}) = ${normalizedGradeName}` - ) - ) - .limit(1) - return row?.id ?? null -} -export const findGradeIdByHeadAndName = cacheFn(findGradeIdByHeadAndNameRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "findGradeIdByHeadAndName"], -}) - -/** - * 将年级名称升级:一年级→二年级,1年级→2年级,Grade 1→Grade 2 - * 无法识别的名称保持不变。 - */ -function promoteGradeName(name: string): string { - // 中文数字映射 - const chineseNums = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"] - for (let i = 0; i < chineseNums.length - 1; i++) { - if (name.includes(chineseNums[i]) && !name.includes(chineseNums[i + 1])) { - return name.replace(chineseNums[i], chineseNums[i + 1]) - } - } - - // 阿拉伯数字 - const match = name.match(/(\d+)/) - if (match) { - const num = parseInt(match[1], 10) - if (num > 0 && num < 13) { - return name.replace(match[1], String(num + 1)) - } - } - - return name -} - -/** - * 年级升级:将指定学校下的所有年级 order +1,并更新年级名称(如果符合数字模式)。 - * 例如:一年级 → 二年级,order 1 → 2 + * 学校数据访问层(barrel 重新导出) * - * 注意:此函数只更新年级本身的 order 和 name,不迁移班级数据。 - * 班级升级应由 classes 模块的独立 Action 处理。 + * 审计 G3-007 / S-01 修复:原 data-access.ts 938 行,拆分为 6 个按职责划分的子文件。 + * 本文件仅作为公共 API 入口,保持向后兼容,所有消费者无需修改 import 路径。 + * + * 子文件结构: + * - data-access-departments.ts: 部门 CRUD + * - data-access-grades.ts: 年级 CRUD + promoteGrades + 跨模块年级接口 + 年级概览统计 + * - data-access-subjects.ts: 科目只读接口(跨模块) + * - data-access-classrooms.ts: 教室只读接口(跨模块,供排课使用) + * - data-access-semesters.ts: 学年 CRUD + * - data-access-schools.ts: 学校 CRUD + 员工选项 + 组织架构树 + * - lib/school-permissions.ts: 角色感知查询(getSchoolsForUser / getGradesForUser) + * + * 变更历史: + * - G3-007 / S-01: 拆分为多文件(938 行 → 单文件 < 300 行) + * - G3-010 / A-10: 移除所有 try-catch 错误吞没 + console.error / log.error,让错误向上抛出 + * - G3-011 / A-02: 角色判断逻辑迁出至 lib/school-permissions.ts,data-access 只接受显式参数 + * - G3-012 / F-09: promoteGrades 改用 db.transaction 包裹循环 UPDATE + * - S-03: 抽取 fetchGradesWithHeads helper,消除 3 处重复代码 */ -export async function promoteGrades(schoolId: string): Promise<{ promoted: number }> { - const rows = await db - .select({ id: grades.id, name: grades.name, order: grades.order }) - .from(grades) - .where(eq(grades.schoolId, schoolId)) - .orderBy(desc(grades.order)) // 从高到低升级,避免唯一约束冲突 - let promoted = 0 - for (const row of rows) { - const newOrder = (row.order ?? 0) + 1 - const newName = promoteGradeName(row.name) - await db - .update(grades) - .set({ order: newOrder, name: newName }) - .where(eq(grades.id, row.id)) - promoted += 1 - } +// G3-048: 对导出数 < 15 的子文件改为显式 re-export;grades(24 个)保留 export *。 +export { + getDepartmentsRaw, + getDepartments, + createDepartment, + updateDepartment, + deleteDepartment, +} from "./data-access-departments" - return { promoted } -} +export { + getSubjectOptionsRaw, + getSubjectOptions, + getSubjectNameByIdRaw, + getSubjectNameById, + getSubjectNameMapByIdsRaw, + getSubjectNameMapByIds, +} from "./data-access-subjects" +export type { SubjectOption } from "./data-access-subjects" -/** - * 获取学校→年级→班级三级组织架构树。 - * 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。 - * 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。 - */ -export const getOrgTreeRaw = async (): Promise => { - try { - const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()]) +export { + getClassroomsForSchedulingRaw, + getClassroomsForScheduling, +} from "./data-access-classrooms" +export type { ClassroomOption } from "./data-access-classrooms" - const { getAdminClasses } = await import("@/modules/classes/data-access") - const allClasses = await getAdminClasses() +export { + getAcademicYearsRaw, + getAcademicYears, + createAcademicYear, + updateAcademicYear, + deleteAcademicYear, +} from "./data-access-semesters" - const classesByGradeId = new Map() - for (const cls of allClasses) { - const gradeId = cls.gradeId - if (typeof gradeId !== "string" || gradeId.length === 0) continue - const node: OrgTreeNode = { - id: cls.id, - name: cls.name, - type: "class", - } - const list = classesByGradeId.get(gradeId) - if (list) { - list.push(node) - } else { - classesByGradeId.set(gradeId, [node]) - } - } +export { + getSchoolsRaw, + getSchools, + createSchool, + updateSchool, + deleteSchool, + getStaffOptionsRaw, + getStaffOptions, + getOrgTreeRaw, + getOrgTree, +} from "./data-access-schools" - const gradesBySchoolId = new Map() - for (const grade of gradeList) { - const children = classesByGradeId.get(grade.id) ?? [] - const node: OrgTreeNode = { - id: grade.id, - name: grade.name, - type: "grade", - children, - } - const list = gradesBySchoolId.get(grade.school.id) - if (list) { - list.push(node) - } else { - gradesBySchoolId.set(grade.school.id, [node]) - } - } +export { + getSchoolsForUserRaw, + getSchoolsForUser, + getGradesForUserRaw, + getGradesForUser, +} from "./lib/school-permissions" - return schoolList.map((school) => ({ - id: school.id, - name: school.name, - type: "school", - children: gradesBySchoolId.get(school.id) ?? [], - })) - } catch (error) { - log.error({ err: error }, "getOrgTree failed") - return [] - } -} -export const getOrgTree = cacheFn(getOrgTreeRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getOrgTree"], -}) - -/** - * 年级概览统计:每个年级的班级数、学生数、教师数。 - * 用于年级管理页面的卡片视图,让管理员一目了然看到各年级规模。 - */ -export interface GradeOverviewStats { - gradeId: string - classCount: number - studentCount: number - teacherCount: number -} - -export const getGradeOverviewStatsRaw = async (): Promise => { - try { - // 动态导入 classes 模块避免循环依赖 - const { getAdminClasses } = await import("@/modules/classes/data-access") - const allClasses = await getAdminClasses() - - // 按年级分组统计班级数和学生数 - const statsByGrade = new Map }>() - - for (const cls of allClasses) { - const gradeId = cls.gradeId - if (typeof gradeId !== "string" || gradeId.length === 0) continue - - const existing = statsByGrade.get(gradeId) ?? { classCount: 0, studentCount: 0, teacherIds: new Set() } - existing.classCount += 1 - existing.studentCount += cls.studentCount ?? 0 - // 班主任算一个教师 - if (cls.teacher?.id) existing.teacherIds.add(cls.teacher.id) - // 学科教师也算 - for (const st of cls.subjectTeachers ?? []) { - if (st.teacher?.id) existing.teacherIds.add(st.teacher.id) - } - statsByGrade.set(gradeId, existing) - } - - return Array.from(statsByGrade.entries()).map(([gradeId, s]) => ({ - gradeId, - classCount: s.classCount, - studentCount: s.studentCount, - teacherCount: s.teacherIds.size, - })) - } catch (error) { - log.error({ err: error }, "getGradeOverviewStats failed") - return [] - } -} -export const getGradeOverviewStats = cacheFn(getGradeOverviewStatsRaw, { - tags: ["school"], - ttl: 300, - keyParts: ["school", "getGradeOverviewStats"], -}) +// grades 子文件导出数 >= 15(24 个),显式列举易遗漏,保留 export *。 +export * from "./data-access-grades" diff --git a/src/modules/settings/components/admin-settings-view.tsx b/src/modules/settings/components/admin-settings-view.tsx index 11a4b8a..590cb7d 100644 --- a/src/modules/settings/components/admin-settings-view.tsx +++ b/src/modules/settings/components/admin-settings-view.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Loader2 } from "lucide-react" import { @@ -98,13 +98,13 @@ export function AdminSettingsView(): React.ReactElement { try { const result = await saveAdminSystemSettingsAction(values) if (result.success) { - toast.success(t("saveSuccess")) + notify.success(t("saveSuccess")) setLoadedValues(values) } else { - toast.error(result.message || t("saveFailure")) + notify.error(result.message || t("saveFailure")) } } catch { - toast.error(t("saveFailure")) + notify.error(t("saveFailure")) } finally { setSaving(false) } diff --git a/src/modules/settings/components/ai-provider-settings-card.tsx b/src/modules/settings/components/ai-provider-settings-card.tsx index 26438dd..f051f0e 100644 --- a/src/modules/settings/components/ai-provider-settings-card.tsx +++ b/src/modules/settings/components/ai-provider-settings-card.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Loader2, Save, Sparkles } from "lucide-react" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -112,7 +112,7 @@ export function AiProviderSettingsCard({ try { const result = await getAiProviderSummaries() if (!result.success || !result.data) { - toast.error(result.message ?? t("loadFailure")) + notify.error(result.message ?? t("loadFailure")) return } const rows = result.data @@ -136,7 +136,7 @@ export function AiProviderSettingsCard({ }) } } catch { - toast.error(t("loadFailure")) + notify.error(t("loadFailure")) } }) }, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t]) @@ -178,7 +178,7 @@ export function AiProviderSettingsCard({ const apiKey = values.apiKey?.trim() const isLocalProvider = values.provider === "ollama" if (!apiKey && !values.id?.trim() && !isLocalProvider) { - toast.error(t("needKey")) + notify.error(t("needKey")) return } setTestStatus("testing") @@ -196,10 +196,10 @@ export function AiProviderSettingsCard({ if (result.success) { setTestStatus("passed") setLastTestedSignature(buildSignature(values)) - toast.success(result.message ?? t("testSuccess")) + notify.success(result.message ?? t("testSuccess")) } else { setTestStatus("failed") - toast.error(result.message ?? t("testFailure")) + notify.error(result.message ?? t("testFailure")) } }) } @@ -207,7 +207,7 @@ export function AiProviderSettingsCard({ const onSubmit = (values: AiProviderFormValues) => { const signature = buildSignature(values) if (testStatus !== "passed" || signature !== lastTestedSignature) { - toast.error(t("needTest")) + notify.error(t("needTest")) return } startTransition(async () => { @@ -222,12 +222,12 @@ export function AiProviderSettingsCard({ } const result = await upsertAiProviderAction(payload) if (result.success) { - toast.success(result.message ?? t("saveSuccess")) + notify.success(result.message ?? t("saveSuccess")) setTestStatus("idle") setLastTestedSignature("") const summariesResult = await getAiProviderSummaries() if (!summariesResult.success || !summariesResult.data) { - toast.error(summariesResult.message ?? t("loadFailure")) + notify.error(summariesResult.message ?? t("loadFailure")) return } const rows = summariesResult.data @@ -248,7 +248,7 @@ export function AiProviderSettingsCard({ }) } } else { - toast.error(result.message ?? t("saveFailure")) + notify.error(result.message ?? t("saveFailure")) } }) } @@ -256,13 +256,13 @@ export function AiProviderSettingsCard({ const handleDelete = () => { const id = form.getValues("id") if (!id?.trim()) { - toast.error(t("deleteNeedSelect")) + notify.error(t("deleteNeedSelect")) return } startTransition(async () => { const result = await deleteAiProviderAction({ id: id.trim() }) if (result.success) { - toast.success(result.message ?? t("deleteSuccess")) + notify.success(result.message ?? t("deleteSuccess")) const summariesResult = await getAiProviderSummaries() if (summariesResult.success && summariesResult.data) { const rows = summariesResult.data @@ -287,7 +287,7 @@ export function AiProviderSettingsCard({ resetToNew() } } else { - toast.error(result.message ?? t("deleteFailure")) + notify.error(result.message ?? t("deleteFailure")) } }) } diff --git a/src/modules/settings/components/avatar-upload.tsx b/src/modules/settings/components/avatar-upload.tsx index 07c5c85..d2ddbb0 100644 --- a/src/modules/settings/components/avatar-upload.tsx +++ b/src/modules/settings/components/avatar-upload.tsx @@ -3,10 +3,11 @@ import * as React from "react" import { useTranslations } from "next-intl" import { Loader2, Trash2, Upload } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { removeUserAvatarAction, updateUserAvatarAction } from "@/modules/settings/actions-avatar" import type { FileUploadResult } from "@/modules/files/types" +import { isFileUploadResult, isMessageBody } from "@/modules/settings/lib/type-guards" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Button } from "@/shared/components/ui/button" @@ -67,7 +68,7 @@ export function AvatarUpload({ const error = validateFile(file) if (error) { - toast.error(error) + notify.error(error) return } @@ -84,11 +85,17 @@ export function AvatarUpload({ }) if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { message?: string } + const raw: unknown = await response.json().catch(() => ({})) + // HTTP 响应不可信,做字段校验 + const body = isMessageBody(raw) ? raw : {} throw new Error(body.message || "Upload failed") } - const result = (await response.json()) as FileUploadResult + const rawResult: unknown = await response.json() + if (!isFileUploadResult(rawResult)) { + throw new Error("Upload failed: invalid response") + } + const result: FileUploadResult = rawResult // 调用 Server Action 更新用户头像 const updateResult = await updateUserAvatarAction(result.url) @@ -98,10 +105,10 @@ export function AvatarUpload({ setPreviewUrl(result.url) onUpdated?.(result.url) - toast.success(t("uploadSuccess")) + notify.success(t("uploadSuccess")) } catch (error) { const message = error instanceof Error ? error.message : t("uploadFailure") - toast.error(message) + notify.error(message) } finally { setUploading(false) if (inputRef.current) { @@ -119,10 +126,10 @@ export function AvatarUpload({ } setPreviewUrl(null) onUpdated?.(null) - toast.success(t("removeSuccess")) + notify.success(t("removeSuccess")) } catch (error) { const message = error instanceof Error ? error.message : t("removeFailure") - toast.error(message) + notify.error(message) } finally { setRemoving(false) } diff --git a/src/modules/settings/components/brand-config-card.tsx b/src/modules/settings/components/brand-config-card.tsx index 58c2cc7..e131227 100644 --- a/src/modules/settings/components/brand-config-card.tsx +++ b/src/modules/settings/components/brand-config-card.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Loader2, Save } from "lucide-react" import { @@ -42,7 +42,7 @@ export function BrandConfigCard(): React.ReactElement { setConfig(res.data) } } catch { - if (!cancelled) toast.error(t("loadFailed")) + if (!cancelled) notify.error(t("loadFailed")) } finally { if (!cancelled) setIsLoading(false) } @@ -65,12 +65,12 @@ export function BrandConfigCard(): React.ReactElement { const res = await saveBrandConfigAction({ success: false }, formData) if (res.success) { - toast.success(t("saveSuccess")) + notify.success(t("saveSuccess")) } else { - toast.error(res.message ?? t("saveFailed")) + notify.error(res.message ?? t("saveFailed")) } } catch { - toast.error(t("saveFailed")) + notify.error(t("saveFailed")) } finally { setIsSaving(false) } diff --git a/src/modules/settings/components/notification-preferences-form.tsx b/src/modules/settings/components/notification-preferences-form.tsx index 67eaed2..4d2d37a 100644 --- a/src/modules/settings/components/notification-preferences-form.tsx +++ b/src/modules/settings/components/notification-preferences-form.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useTransition } from "react" import { useTranslations } from "next-intl" import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon, Send } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { sendTestNotificationAction } from "@/modules/settings/actions-notifications" import { Button } from "@/shared/components/ui/button" @@ -136,12 +136,12 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere quietHoursEnd: quietHours.quietHoursEnd || null, }) if (result.success) { - toast.success(t("success")) + notify.success(t("success")) } else { - toast.error(result.message || t("failure")) + notify.error(result.message || t("failure")) } } catch { - toast.error(t("failure")) + notify.error(t("failure")) } }) } @@ -151,12 +151,12 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere try { const result = await sendTestNotificationAction({ channel }) if (result.success) { - toast.success(t("testSuccess")) + notify.success(t("testSuccess")) } else { - toast.error(result.message || t("testFailure")) + notify.error(result.message || t("testFailure")) } } catch { - toast.error(t("testFailure")) + notify.error(t("testFailure")) } finally { setTestingChannel(null) } diff --git a/src/modules/settings/components/password-change-form.tsx b/src/modules/settings/components/password-change-form.tsx index 2f7dc64..f388f7d 100644 --- a/src/modules/settings/components/password-change-form.tsx +++ b/src/modules/settings/components/password-change-form.tsx @@ -4,7 +4,7 @@ import { useActionState, useEffect, useMemo, useRef, useState } from "react" import { useFormStatus } from "react-dom" import { useTranslations } from "next-intl" import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -62,10 +62,10 @@ export function PasswordChangeForm() { useEffect(() => { if (state?.success) { - toast.success(state.message ?? t("success")) + notify.success(state.message ?? t("success")) formRef.current?.reset() } else if (state?.message) { - toast.error(state.message) + notify.error(state.message) } }, [state, t]) diff --git a/src/modules/settings/components/profile-settings-form.tsx b/src/modules/settings/components/profile-settings-form.tsx index c81ebaf..ddb860b 100644 --- a/src/modules/settings/components/profile-settings-form.tsx +++ b/src/modules/settings/components/profile-settings-form.tsx @@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { z } from "zod" import { Loader2, Save } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -65,12 +65,12 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }): ReactEleme age: ageNum !== undefined && !Number.isNaN(ageNum) ? ageNum : undefined, }) if (result.success) { - toast.success(t("success")) + notify.success(t("success")) } else { - toast.error(result.message || t("failure")) + notify.error(result.message || t("failure")) } } catch { - toast.error(t("failure")) + notify.error(t("failure")) } }) } diff --git a/src/modules/settings/components/security-recent-logins-section.tsx b/src/modules/settings/components/security-recent-logins-section.tsx index e096a15..ebf5e6c 100644 --- a/src/modules/settings/components/security-recent-logins-section.tsx +++ b/src/modules/settings/components/security-recent-logins-section.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useLocale, useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { LogIn, LogOut, @@ -65,16 +65,16 @@ export function SecurityRecentLoginsSection({ const result = await revokeAllOtherSessionsAction() if (result.success && result.data) { if (result.data.revokedCount > 0) { - toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount })) + notify.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount })) } else { - toast.info(t("recentLogins.revokeSuccessEmpty")) + notify.info(t("recentLogins.revokeSuccessEmpty")) } await onRevoked() } else { - toast.error(result.message || t("recentLogins.revokeFailure")) + notify.error(result.message || t("recentLogins.revokeFailure")) } } catch { - toast.error(t("recentLogins.revokeFailure")) + notify.error(t("recentLogins.revokeFailure")) } finally { setRevoking(false) } diff --git a/src/modules/settings/components/security-two-factor-section.tsx b/src/modules/settings/components/security-two-factor-section.tsx index 8f06090..d69270a 100644 --- a/src/modules/settings/components/security-two-factor-section.tsx +++ b/src/modules/settings/components/security-two-factor-section.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Smartphone, Loader2, @@ -98,11 +98,11 @@ export function SecurityTwoFactorSection({ setSetupData(result.data) setSetupStep("qr") } else { - toast.error(result.message || t("twoFactor.setupFailure")) + notify.error(result.message || t("twoFactor.setupFailure")) setEnableDialogOpen(false) } } catch { - toast.error(t("twoFactor.setupFailure")) + notify.error(t("twoFactor.setupFailure")) setEnableDialogOpen(false) } finally { setSetupLoading(false) @@ -118,12 +118,12 @@ export function SecurityTwoFactorSection({ setBackupCodes(result.data.backupCodes) onStatusChange(result.data.status) setSetupStep("backup") - toast.success(t("twoFactor.enableSuccess")) + notify.success(t("twoFactor.enableSuccess")) } else { - toast.error(result.message || t("twoFactor.invalidCode")) + notify.error(result.message || t("twoFactor.invalidCode")) } } catch { - toast.error(t("twoFactor.verifyFailure")) + notify.error(t("twoFactor.verifyFailure")) } finally { setSetupLoading(false) } @@ -158,12 +158,12 @@ export function SecurityTwoFactorSection({ onStatusChange(result.data) setDisableDialogOpen(false) setDisableCode("") - toast.success(t("twoFactor.disableSuccess")) + notify.success(t("twoFactor.disableSuccess")) } else { - toast.error(result.message || t("twoFactor.invalidCode")) + notify.error(result.message || t("twoFactor.invalidCode")) } } catch { - toast.error(t("twoFactor.disableFailure")) + notify.error(t("twoFactor.disableFailure")) } finally { setDisableLoading(false) } @@ -180,12 +180,12 @@ export function SecurityTwoFactorSection({ setRegenBackupCodes(result.data.backupCodes) onStatusChange(result.data.status) setRegenCode("") - toast.success(t("twoFactor.regenerateSuccess")) + notify.success(t("twoFactor.regenerateSuccess")) } else { - toast.error(result.message || t("twoFactor.invalidCode")) + notify.error(result.message || t("twoFactor.invalidCode")) } } catch { - toast.error(t("twoFactor.regenerateFailure")) + notify.error(t("twoFactor.regenerateFailure")) } finally { setRegenLoading(false) } diff --git a/src/modules/standards/data-access.ts b/src/modules/standards/data-access.ts index c378ea7..5b881f4 100644 --- a/src/modules/standards/data-access.ts +++ b/src/modules/standards/data-access.ts @@ -16,6 +16,7 @@ import type { GetStandardsParams, } from "./types"; import type { StandardLevel } from "../lesson-preparation/lib/type-guards"; +import { toStandardLevel } from "./lib/type-guards"; /** * 查询课标列表(可按层级/学科/年级过滤,可选返回树形结构) @@ -253,7 +254,7 @@ function mapRowToStandard(row: typeof standards.$inferSelect): Standard { code: row.code, title: row.title, description: row.description ?? undefined, - level: row.level as StandardLevel, + level: toStandardLevel(row.level), parentId: row.parentId ?? undefined, subjectId: row.subjectId ?? undefined, gradeId: row.gradeId ?? undefined, diff --git a/src/modules/student/components/student-courses-view.tsx b/src/modules/student/components/student-courses-view.tsx index 39515d3..d8eff0b 100644 --- a/src/modules/student/components/student-courses-view.tsx +++ b/src/modules/student/components/student-courses-view.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { memo, useState, useTransition } from "react" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card" @@ -118,15 +118,15 @@ export function StudentCoursesView({ try { const res = await joinClassByInvitationCodeAction(null, formData) if (res.success) { - toast.success(res.message || t("coursesView.joinedSuccess")) + notify.success(res.message || t("coursesView.joinedSuccess")) setCode("") router.refresh() } else { - toast.error(res.message || t("coursesView.joinFailed")) + notify.error(res.message || t("coursesView.joinFailed")) } } catch (err) { console.error("[joinClass] failed:", err) - toast.error(t("coursesView.joinFailed")) + notify.error(t("coursesView.joinFailed")) } }) } diff --git a/src/modules/textbooks/components/chapter-sidebar-list.tsx b/src/modules/textbooks/components/chapter-sidebar-list.tsx index 99a42f0..6c495d8 100644 --- a/src/modules/textbooks/components/chapter-sidebar-list.tsx +++ b/src/modules/textbooks/components/chapter-sidebar-list.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core" import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable" @@ -245,13 +245,13 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte try { const result = await reorderChaptersAction(String(active.id), newIndex, activeParentId, textbookId) if (result.success) { - toast.success(t("dialog.chapter.orderUpdated")) + notify.success(t("dialog.chapter.orderUpdated")) } else { - toast.error(result.message || t("dialog.chapter.orderUpdateFailed")) + notify.error(result.message || t("dialog.chapter.orderUpdateFailed")) } } catch (e) { console.error("Failed to reorder chapters", e) - toast.error(t("dialog.chapter.orderUpdateFailed")) + notify.error(t("dialog.chapter.orderUpdateFailed")) } } } @@ -262,15 +262,15 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte try { const res = await deleteChapterAction(deleteTarget.id, textbookId) if (res.success) { - toast.success(res.message) + notify.success(res.message) setShowDeleteDialog(false) setDeleteTarget(null) } else { - toast.error(res.message) + notify.error(res.message) } } catch (e) { console.error("Failed to delete chapter", e) - toast.error(t("reader.deleteFailed")) + notify.error(t("reader.deleteFailed")) } finally { setIsDeleting(false) } @@ -278,7 +278,7 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte const handleDeleteRequest = (chapter: Chapter) => { if (chapter.children && chapter.children.length > 0) { - toast.error(t("dialog.chapter.cannotDeleteWithSubchapters")) + notify.error(t("dialog.chapter.cannotDeleteWithSubchapters")) return } setDeleteTarget(chapter) diff --git a/src/modules/textbooks/components/create-chapter-dialog.tsx b/src/modules/textbooks/components/create-chapter-dialog.tsx index a13dd3c..0bda560 100644 --- a/src/modules/textbooks/components/create-chapter-dialog.tsx +++ b/src/modules/textbooks/components/create-chapter-dialog.tsx @@ -17,7 +17,7 @@ import { import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { createChapterAction } from "../actions" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" function SubmitButton() { const { pending } = useFormStatus() @@ -53,14 +53,14 @@ export function CreateChapterDialog({ try { const result = await createChapterAction(textbookId, parentId, null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) setOpen(false) } else { - toast.error(result.message) + notify.error(result.message) } } catch (e) { console.error("Failed to create chapter", e) - toast.error(t("reader.createFailed")) + notify.error(t("reader.createFailed")) } } diff --git a/src/modules/textbooks/components/force-graph.tsx b/src/modules/textbooks/components/force-graph.tsx index 4c94489..4e299aa 100644 --- a/src/modules/textbooks/components/force-graph.tsx +++ b/src/modules/textbooks/components/force-graph.tsx @@ -28,10 +28,12 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import type { GraphViewMode, KnowledgeGraphData, - KpWithRelations, + KpGraphNode, + KpGraphLink, MasteryInfo, MasteryLevel, } from "../types" +import { isKpGraphNode, isKpGraphLink } from "../lib/type-guards" /** 章节颜色调色板(柔和、Obsidian 风格) */ const CHAPTER_COLORS = [ @@ -95,25 +97,8 @@ function getMasteryLevel(mastery: MasteryInfo | null): MasteryLevel { return "high" } -/** 力导向图谱节点 */ -interface KpGraphNode extends NodeObject { - id: string - name: string - kp: KpWithRelations - mastery: MasteryInfo | null - /** 连接数(决定节点大小) */ - val: number - /** 章节颜色 */ - chapterColor: string -} - -/** 力导向图谱边 */ -interface KpGraphLink { - source: string | KpGraphNode - target: string | KpGraphNode - /** 边类型:parent(树归属)/ prerequisite(依赖) */ - edgeType: "parent" | "prerequisite" -} +/** 力导向图谱节点 KpGraphNode / 边 KpGraphLink 类型定义已迁移至 ../types, + * 供 lib/type-guards.ts 类型守卫引用,避免组件文件被守卫模块反向依赖。 */ interface ForceGraphProps { data: KnowledgeGraphData @@ -288,7 +273,9 @@ function ForceGraphInner({ // 节点 Canvas 绘制(Obsidian 风格) const nodeCanvasObject = useCallback( (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { - const kpNode = node as KpGraphNode + // 类型守卫收窄 NodeObject → KpGraphNode(替代 as 断言) + if (!isKpGraphNode(node)) return + const kpNode = node const highlightState = getNodeHighlightState( kpNode.id, searchText, @@ -361,9 +348,15 @@ function ForceGraphInner({ // 边绘制(Obsidian 风格:极细半透明线,颜色区分类型) const linkCanvasObject = useCallback( (link: LinkObject, ctx: CanvasRenderingContext2D) => { - const kpLink = link as KpGraphLink - const source = kpLink.source as KpGraphNode - const target = kpLink.target as KpGraphNode + // 类型守卫收窄 LinkObject → KpGraphLink(替代 as 断言) + if (!isKpGraphLink(link)) return + const kpLink = link + // react-force-graph-2d 模拟后 source/target 会被库内部从 string id 替换为节点对象引用 + const source = kpLink.source + const target = kpLink.target + if (typeof source === "string" || typeof target === "string") return + // 运行时再次校验 source/target 确为业务 KpGraphNode(防止库内部替换为非业务节点) + if (!isKpGraphNode(source) || !isKpGraphNode(target)) return if (!source.x || !source.y || !target.x || !target.y) return // 高亮逻辑:有焦点时仅高亮含邻居节点的边 @@ -388,8 +381,9 @@ function ForceGraphInner({ const handleNodeClick = useCallback( (node: NodeObject) => { - const kpNode = node as KpGraphNode - onSelectKp(kpNode.id === selectedKpId ? null : kpNode.id) + // 类型守卫收窄 NodeObject → KpGraphNode(替代 as 断言) + if (!isKpGraphNode(node)) return + onSelectKp(node.id === selectedKpId ? null : node.id) }, [onSelectKp, selectedKpId], ) @@ -399,7 +393,9 @@ function ForceGraphInner({ }, [onSelectKp]) const handleNodeHover = useCallback((node: NodeObject | null) => { - setHoveredKpId(node ? (node as KpGraphNode).id : null) + // 类型守卫收窄 NodeObject → KpGraphNode(替代 as 断言) + const kpId = node && isKpGraphNode(node) ? node.id : null + setHoveredKpId(kpId) if (containerRef.current) { containerRef.current.style.cursor = node ? "pointer" : "default" } diff --git a/src/modules/textbooks/components/graph-kp-node.tsx b/src/modules/textbooks/components/graph-kp-node.tsx index fa69606..66526da 100644 --- a/src/modules/textbooks/components/graph-kp-node.tsx +++ b/src/modules/textbooks/components/graph-kp-node.tsx @@ -5,6 +5,7 @@ import { Handle, Position, type NodeProps } from "@xyflow/react" import { useTranslations } from "next-intl" import { cn } from "@/shared/lib/utils" import type { GraphNodeData, MasteryLevel, KpWithRelations } from "../types" +import { isGraphNodeData, isKpWithRelations } from "../lib/type-guards" import { NODE_WIDTH } from "../graph-layout" /** 根据掌握度计算色彩等级 */ @@ -30,22 +31,24 @@ const MASTERY_BAR_COLORS: Record = { } /** - * 类型守卫:从 React Flow node.data(Record)安全提取图谱节点数据。 - * 替代 as unknown as 双重断言,提供运行时安全。 + * 从 React Flow node.data(Record)安全提取图谱节点数据。 + * 使用类型守卫替代 as 断言,运行时校验失败返回 null(组件渲染 null 而非崩溃)。 */ function extractNodeData(data: Record): { kp: KpWithRelations graphData?: GraphNodeData -} { - const kp = data.kp as KpWithRelations - const graphData = data.graphData as GraphNodeData | undefined - return { kp, graphData } +} | null { + if (!isKpWithRelations(data.kp)) return null + const graphData = isGraphNodeData(data.graphData) ? data.graphData : undefined + return { kp: data.kp, graphData } } function GraphKpNodeComponent(props: NodeProps) { const t = useTranslations("textbooks") const { data, selected } = props - const { kp, graphData } = extractNodeData(data) + const extracted = extractNodeData(data) + if (!extracted) return null + const { kp, graphData } = extracted const mastery = graphData?.mastery ?? null const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null) const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery" diff --git a/src/modules/textbooks/components/graph-prerequisite-edge.tsx b/src/modules/textbooks/components/graph-prerequisite-edge.tsx index c1f8b96..03cb7d5 100644 --- a/src/modules/textbooks/components/graph-prerequisite-edge.tsx +++ b/src/modules/textbooks/components/graph-prerequisite-edge.tsx @@ -4,6 +4,7 @@ import { memo } from "react" import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react" import { cn } from "@/shared/lib/utils" import type { GraphEdgeData } from "../types" +import { isGraphEdgeData } from "../lib/type-guards" function GraphPrerequisiteEdgeComponent({ id, @@ -24,8 +25,9 @@ function GraphPrerequisiteEdgeComponent({ targetPosition, }) - // EdgeProps.data 类型为 Record,经 unknown 安全转换读取 GraphEdgeData - const edgeData = data as unknown as GraphEdgeData | undefined + // 类型守卫收窄 EdgeProps.data(Record | undefined)→ GraphEdgeData + // 替代 as unknown as 双重断言,提供运行时安全 + const edgeData: GraphEdgeData | undefined = isGraphEdgeData(data) ? data : undefined const isHighlighted = edgeData?.isHighlighted ?? false return ( diff --git a/src/modules/textbooks/components/knowledge-graph-node.tsx b/src/modules/textbooks/components/knowledge-graph-node.tsx index 1434d95..1020fbf 100644 --- a/src/modules/textbooks/components/knowledge-graph-node.tsx +++ b/src/modules/textbooks/components/knowledge-graph-node.tsx @@ -20,6 +20,7 @@ import "@xyflow/react/dist/style.css" import type { GraphLayoutMode, GraphViewMode, KnowledgeGraphData } from "../types" import type { GraphLayoutNodeData } from "../graph-layout" +import { isGraphLayoutNodeData } from "../lib/type-guards" import { GraphKpNode } from "./graph-kp-node" import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge" import { ForceKnowledgeGraph } from "./force-graph" @@ -78,10 +79,11 @@ export function KnowledgeGraphNode({ { - // 安全的类型收窄:node.data 是 Record, - // GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型 - const nodeData = node.data as GraphLayoutNodeData - return nodeData.graphData?.chapterColor ?? "hsl(var(--muted-foreground))" + // 类型守卫收窄 node.data(Record)→ GraphLayoutNodeData + const nodeData: GraphLayoutNodeData | undefined = isGraphLayoutNodeData(node.data) + ? node.data + : undefined + return nodeData?.graphData?.chapterColor ?? "hsl(var(--muted-foreground))" }} /> diff --git a/src/modules/textbooks/components/textbook-card.tsx b/src/modules/textbooks/components/textbook-card.tsx index b61c4b2..b92a045 100644 --- a/src/modules/textbooks/components/textbook-card.tsx +++ b/src/modules/textbooks/components/textbook-card.tsx @@ -33,7 +33,7 @@ import { cn, formatDate } from "@/shared/lib/utils" import type { Textbook } from "../types" import { getSubjectColor, getSubjectLabelKey, getGradeLabelKey } from "../constants" import { deleteTextbookAction } from "../actions" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" interface TextbookCardProps { textbook: Textbook @@ -54,14 +54,14 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr try { const result = await deleteTextbookAction(textbook.id) if (result.success) { - toast.success(result.message) + notify.success(result.message) router.refresh() } else { - toast.error(result.message) + notify.error(result.message) } } catch (e) { console.error("Failed to delete textbook", e) - toast.error(t("reader.deleteFailed")) + notify.error(t("reader.deleteFailed")) } finally { setIsDeleting(false) setShowDeleteDialog(false) diff --git a/src/modules/textbooks/components/textbook-form-dialog.tsx b/src/modules/textbooks/components/textbook-form-dialog.tsx index 0fbd074..8d063ef 100644 --- a/src/modules/textbooks/components/textbook-form-dialog.tsx +++ b/src/modules/textbooks/components/textbook-form-dialog.tsx @@ -15,7 +15,7 @@ import { DialogTrigger, } from "@/shared/components/ui/dialog" import { createTextbookAction } from "../actions" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { TextbookFormFields } from "./textbook-form-fields" function SubmitButton() { @@ -36,14 +36,14 @@ export function TextbookFormDialog() { try { const result = await createTextbookAction(null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) setOpen(false) } else { - toast.error(result.message) + notify.error(result.message) } } catch (e) { console.error("Failed to create textbook", e) - toast.error(t("reader.createFailed")) + notify.error(t("reader.createFailed")) } } diff --git a/src/modules/textbooks/components/textbook-reader.tsx b/src/modules/textbooks/components/textbook-reader.tsx index a1df7bf..602d55b 100644 --- a/src/modules/textbooks/components/textbook-reader.tsx +++ b/src/modules/textbooks/components/textbook-reader.tsx @@ -3,7 +3,7 @@ import { useMemo, useState, useEffect, useRef, type ReactNode } from "react" import { useQueryState, parseAsString } from "nuqs" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import type { Chapter, KnowledgePoint } from "../types" import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions" @@ -162,15 +162,15 @@ export function TextbookReader({ const result = await updateChapterContentAction(selectedId, editContent, textbookId) if (result.success) { - toast.success(result.message) + notify.success(result.message) setIsEditing(false) setLocalContent(editContent) } else { - toast.error(result.message) + notify.error(result.message) } } catch (e) { console.error("Failed to save chapter content", e) - toast.error(t("reader.saveFailed")) + notify.error(t("reader.saveFailed")) } finally { setIsSaving(false) } diff --git a/src/modules/textbooks/components/textbook-settings-dialog.tsx b/src/modules/textbooks/components/textbook-settings-dialog.tsx index 93ffc17..c1e6ba7 100644 --- a/src/modules/textbooks/components/textbook-settings-dialog.tsx +++ b/src/modules/textbooks/components/textbook-settings-dialog.tsx @@ -25,7 +25,7 @@ import { AlertDialogTitle, } from "@/shared/components/ui/alert-dialog" import { updateTextbookAction, deleteTextbookAction } from "../actions" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import type { Textbook } from "../types" import { TextbookFormFields } from "./textbook-form-fields" @@ -48,14 +48,14 @@ export function TextbookSettingsDialog({ textbook, trigger, redirectAfterDelete try { const result = await updateTextbookAction(textbook.id, null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) setOpen(false) } else { - toast.error(result.message) + notify.error(result.message) } } catch (e) { console.error("Failed to update textbook", e) - toast.error(t("reader.updateFailed")) + notify.error(t("reader.updateFailed")) } finally { setLoading(false) } @@ -69,14 +69,14 @@ export function TextbookSettingsDialog({ textbook, trigger, redirectAfterDelete const result = await deleteTextbookAction(textbook.id) if (result.success) { - toast.success(result.message) + notify.success(result.message) router.push(redirectAfterDelete) } else { - toast.error(result.message) + notify.error(result.message) } } catch (e) { console.error("Failed to delete textbook", e) - toast.error(t("reader.deleteFailed")) + notify.error(t("reader.deleteFailed")) } finally { setLoading(false) } diff --git a/src/modules/textbooks/data-access-graph.ts b/src/modules/textbooks/data-access-graph.ts index 04557e1..78355ac 100644 --- a/src/modules/textbooks/data-access-graph.ts +++ b/src/modules/textbooks/data-access-graph.ts @@ -1,6 +1,6 @@ import "server-only" -import { and, asc, eq, inArray, sql, count } from "drizzle-orm" +import { and, asc, eq, inArray } from "drizzle-orm" import { cacheFn } from "@/shared/lib/cache" import { db } from "@/shared/db" @@ -8,9 +8,10 @@ import { chapters, knowledgePoints, knowledgePointPrerequisites, - questionsToKnowledgePoints, - knowledgePointMastery, } from "@/shared/db/schema" +import { getQuestionCountByKpIds } from "@/modules/questions/data-access" +import { getStudentMasteryByKpIds, getClassMasteryByKpIds } from "@/modules/diagnostic/data-access" +import { getKnowledgePointsByTextbookId } from "./data-access" import type { KpWithRelations, MasteryInfo } from "./types" /** @@ -42,20 +43,9 @@ export const getKnowledgePointsWithRelationsRaw = async ( const kpIds = kpRows.map((r) => r.id) - // 2. 查询关联题目数(批量聚合) - const questionCountRows = await db - .select({ - knowledgePointId: questionsToKnowledgePoints.knowledgePointId, - count: count(), - }) - .from(questionsToKnowledgePoints) - .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) - .groupBy(questionsToKnowledgePoints.knowledgePointId) - - const questionCountMap = new Map() - for (const r of questionCountRows) { - questionCountMap.set(r.knowledgePointId, Number(r.count)) - } + // 2. 查询关联题目数(跨模块批量聚合,通过 questions 模块 data-access 调用, + // 避免直接查询 questionsToKnowledgePoints 表) + const questionCountMap = await getQuestionCountByKpIds(kpIds) // 3. 查询前置依赖(批量,仅查询属于当前教材知识点的依赖) // 双向过滤:knowledgePointId 和 prerequisiteKpId 都必须在当前教材的知识点集合内 @@ -99,37 +89,20 @@ export const getKnowledgePointsWithRelations = cacheFn(getKnowledgePointsWithRel /** * 获取学生在某教材下所有知识点的掌握度。 + * + * 通过 textbooks 模块 data-access 获取教材下知识点 ID 集合, + * 再调用 diagnostic 模块 data-access 按知识点 ID 查询掌握度, + * 避免直接查询 knowledgePointMastery 表。 */ export const getStudentKpMasteryRaw = async ( studentId: string, textbookId: string, ): Promise> => { - const rows = await db - .select({ - knowledgePointId: knowledgePointMastery.knowledgePointId, - masteryLevel: knowledgePointMastery.masteryLevel, - totalQuestions: knowledgePointMastery.totalQuestions, - correctQuestions: knowledgePointMastery.correctQuestions, - lastAssessedAt: knowledgePointMastery.lastAssessedAt, - }) - .from(knowledgePointMastery) - .innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) - .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .where(and( - eq(knowledgePointMastery.studentId, studentId), - eq(chapters.textbookId, textbookId), - )) + const kps = await getKnowledgePointsByTextbookId(textbookId) + const kpIds = kps.map((kp) => kp.id) + if (kpIds.length === 0) return new Map() - const map = new Map() - for (const r of rows) { - map.set(r.knowledgePointId, { - masteryLevel: Number(r.masteryLevel), - totalQuestions: r.totalQuestions, - correctQuestions: r.correctQuestions, - lastAssessedAt: r.lastAssessedAt, - }) - } - return map + return await getStudentMasteryByKpIds(studentId, kpIds) } export const getStudentKpMastery = cacheFn(getStudentKpMasteryRaw, { tags: ["textbooks"], @@ -140,6 +113,10 @@ export const getStudentKpMastery = cacheFn(getStudentKpMasteryRaw, { /** * 获取班级(教师所带班级的所有学生)在某教材下知识点的平均掌握度。 * + * 通过 textbooks 模块 data-access 获取教材下知识点 ID 集合, + * 再调用 diagnostic 模块 data-access 按知识点 ID 聚合掌握度, + * 避免直接查询 knowledgePointMastery 表。 + * * @param studentIds 班级学生 ID 列表 * @param textbookId 教材 ID */ @@ -149,33 +126,11 @@ export const getClassKpMasteryRaw = async ( ): Promise> => { if (studentIds.length === 0) return new Map() - const rows = await db - .select({ - knowledgePointId: knowledgePointMastery.knowledgePointId, - avgMastery: sql`AVG(${knowledgePointMastery.masteryLevel})`, - totalQuestions: sql`SUM(${knowledgePointMastery.totalQuestions})`, - correctQuestions: sql`SUM(${knowledgePointMastery.correctQuestions})`, - lastAssessedAt: sql`MAX(${knowledgePointMastery.lastAssessedAt})`, - }) - .from(knowledgePointMastery) - .innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) - .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .where(and( - inArray(knowledgePointMastery.studentId, studentIds), - eq(chapters.textbookId, textbookId), - )) - .groupBy(knowledgePointMastery.knowledgePointId) + const kps = await getKnowledgePointsByTextbookId(textbookId) + const kpIds = kps.map((kp) => kp.id) + if (kpIds.length === 0) return new Map() - const map = new Map() - for (const r of rows) { - map.set(r.knowledgePointId, { - masteryLevel: Number(r.avgMastery), - totalQuestions: Number(r.totalQuestions), - correctQuestions: Number(r.correctQuestions), - lastAssessedAt: r.lastAssessedAt, - }) - } - return map + return await getClassMasteryByKpIds(studentIds, kpIds) } export const getClassKpMastery = cacheFn(getClassKpMasteryRaw, { tags: ["textbooks"], diff --git a/src/modules/textbooks/data-access.ts b/src/modules/textbooks/data-access.ts index 6e9ca0c..e8b1ae2 100644 --- a/src/modules/textbooks/data-access.ts +++ b/src/modules/textbooks/data-access.ts @@ -31,6 +31,9 @@ import { export { buildChapterTree, findChapterById, normalizeOptional, sortChapters } +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000 + /** * 数据范围过滤参数。 * 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。 @@ -45,7 +48,8 @@ export const getTextbooksRaw = async (query?: string, subject?: string, grade?: const q = query?.trim() if (q) { - const needle = `%${escapeLikePattern(q)}%` + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 + const needle = `${escapeLikePattern(q)}%` const nameCond = or( like(textbooks.title, needle), like(textbooks.subject, needle), @@ -77,6 +81,7 @@ export const getTextbooksRaw = async (query?: string, subject?: string, grade?: .where(conditions.length ? and(...conditions) : undefined) .groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt) .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) + .limit(DEFAULT_LIMIT) return rows.map((r) => ({ id: r.id, @@ -167,6 +172,12 @@ export const getChaptersByTextbookId = cacheFn(getChaptersByTextbookIdRaw, { keyParts: ["textbooks", "getChaptersByTextbookId"], }) +/** + * 创建教材。 + * + * @param data - 教材输入(title/subject/grade/publisher) + * @returns 新创建的教材对象(chaptersCount 为 0) + */ export async function createTextbook(data: CreateTextbookInput): Promise { const id = createId() const now = new Date() @@ -189,6 +200,13 @@ export async function createTextbook(data: CreateTextbookInput): Promise { await db .update(textbooks) @@ -205,10 +223,21 @@ export async function updateTextbook(data: UpdateTextbookInput): Promise { await db.delete(textbooks).where(eq(textbooks.id, id)) } +/** + * 在指定教材下创建章节(content 初始为空字符串,children 为空数组)。 + * + * @param data - 章节输入(textbookId/title/order?/parentId?) + * @returns 新创建的章节对象 + */ export async function createChapter(data: CreateChapterInput): Promise { const id = createId() const now = new Date() @@ -239,6 +268,13 @@ export async function createChapter(data: CreateChapterInput): Promise } } +/** + * 更新章节正文 content 字段,并返回更新后的章节对象。 + * + * @param data - 更新输入(chapterId + content) + * @returns 更新后的章节对象(children 为空数组) + * @throws {NotFoundError} 当章节不存在时抛出 + */ export async function updateChapterContent(data: UpdateChapterContentInput): Promise { await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId)) @@ -272,6 +308,15 @@ export async function updateChapterContent(data: UpdateChapterContentInput): Pro } } +/** + * 删除章节及其所有后代章节,并清理后代知识点及其前置依赖记录。 + * + * 通过本地构建 childrenByParent 索引 + 栈遍历收集所有后代 ID, + * 在事务内:1) 清理 knowledgePointPrerequisites 表中引用被删知识点的孤儿记录; + * 2) 删除知识点;3) 删除章节。 + * + * @param id - 待删除的章节 ID + */ export async function deleteChapter(id: string): Promise { const [target] = await db .select({ id: chapters.id, textbookId: chapters.textbookId }) @@ -332,6 +377,12 @@ export async function deleteChapter(id: string): Promise { }) } +/** + * 获取指定章节下的知识点列表,按 order 与 name 升序。 + * + * @param chapterId - 章节 ID + * @returns 知识点数组 + */ export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise => { const rows = await db .select({ @@ -363,6 +414,13 @@ export const getKnowledgePointsByChapterId = cacheFn(getKnowledgePointsByChapter keyParts: ["textbooks", "getKnowledgePointsByChapterId"], }) +/** + * 获取指定教材下的全部知识点(通过 chapters 表 JOIN 过滤),按章节 order、 + * 知识点 order、知识点 name 升序。 + * + * @param textbookId - 教材 ID + * @returns 知识点数组 + */ export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise => { const rows = await db .select({ @@ -395,6 +453,11 @@ export const getKnowledgePointsByTextbookId = cacheFn(getKnowledgePointsByTextbo keyParts: ["textbooks", "getKnowledgePointsByTextbookId"], }) +/** + * 创建知识点(level/order 默认 0,需调用方按需更新)。 + * + * @param data - 知识点输入(name/description?/anchorText?/chapterId/parentId?) + */ export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise { await db.insert(knowledgePoints).values({ id: createId(), @@ -408,6 +471,11 @@ export async function createKnowledgePoint(data: CreateKnowledgePointInput): Pro }) } +/** + * 按 ID 更新知识点(name/description/anchorText),不动 level/order/parentId。 + * + * @param data - 知识点更新输入(id/name/description?/anchorText?) + */ export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise { await db .update(knowledgePoints) @@ -419,10 +487,26 @@ export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Pro .where(eq(knowledgePoints.id, data.id)) } +/** + * 按 ID 删除知识点。 + * + * @param id - 待删除的知识点 ID + */ export async function deleteKnowledgePoint(id: string): Promise { await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)) } +/** + * 调整章节在同级中的位置(可同时切换 parentId),单事务内批量更新 order。 + * + * 流程:1) 读取目标章节;2) 查询同 parent 下兄弟;3) 移除目标后插入到 newIndex; + * 4) 在事务内并行 UPDATE 变更行(order 或 parentId)。 + * + * @param chapterId - 待移动的章节 ID + * @param newIndex - 目标位置(0-based) + * @param parentId - 目标父章节 ID(顶层为 null) + * @throws 当章节不存在时抛出 NotFoundError + */ export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise { const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1) if (!target) throw new NotFoundError("章节") @@ -442,18 +526,22 @@ export async function reorderChapters(chapterId: string, newIndex: number, paren currentSiblings.splice(newIndex, 0, target) await db.transaction(async (tx) => { - for (let i = 0; i < currentSiblings.length; i++) { - const ch = currentSiblings[i] - if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) { - await tx - .update(chapters) - .set({ - order: i, - parentId: ch.id === chapterId ? parentId : ch.parentId - }) - .where(eq(chapters.id, ch.id)) - } - } + // 批量并行 UPDATE 替代原先串行 await 循环,单事务保证原子性。 + // 仅对需要变更的行发起 UPDATE(order 变化或 parentId 变化)。 + await Promise.all( + currentSiblings.map((ch, i) => { + if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) { + return tx + .update(chapters) + .set({ + order: i, + parentId: ch.id === chapterId ? parentId : ch.parentId + }) + .where(eq(chapters.id, ch.id)) + } + return Promise.resolve() + }) + ) }) } @@ -462,6 +550,11 @@ export type TextbooksDashboardStats = { chapterCount: number } +/** + * 获取教材仪表盘统计(教材总数与章节总数)。 + * + * @returns 包含 textbookCount / chapterCount 的统计对象 + */ export const getTextbooksDashboardStatsRaw = async (): Promise => { const [textbookCountRow, chapterCountRow] = await Promise.all([ db.select({ value: count() }).from(textbooks), @@ -489,7 +582,7 @@ export const getTextbooksDashboardStats = cacheFn(getTextbooksDashboardStatsRaw, * * @returns true 表示归属一致 */ -export async function verifyChapterBelongsToTextbook( +export async function verifyChapterBelongsToTextbookRaw( chapterId: string, textbookId: string ): Promise { @@ -502,13 +595,18 @@ export async function verifyChapterBelongsToTextbook( if (!row) return false return row.textbookId === textbookId } +export const verifyChapterBelongsToTextbook = cacheFn(verifyChapterBelongsToTextbookRaw, { + tags: ["textbooks"], + ttl: 60, + keyParts: ["textbooks", "verifyChapterBelongsToTextbook"], +}) /** * 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。 * * 用于 Server Action 二次校验,防止越权操作其他教材的知识点。 */ -export async function verifyKnowledgePointBelongsToTextbook( +export async function verifyKnowledgePointBelongsToTextbookRaw( kpId: string, textbookId: string ): Promise { @@ -522,6 +620,11 @@ export async function verifyKnowledgePointBelongsToTextbook( if (!row) return false return row.textbookId === textbookId } +export const verifyKnowledgePointBelongsToTextbook = cacheFn(verifyKnowledgePointBelongsToTextbookRaw, { + tags: ["textbooks"], + ttl: 60, + keyParts: ["textbooks", "verifyKnowledgePointBelongsToTextbook"], +}) // --------------------------------------------------------------------------- // 带 scope 的查询(P1-1 数据范围过滤) @@ -542,7 +645,8 @@ export const getTextbooksWithScopeRaw = async ( const q = query?.trim() if (q) { - const needle = `%${escapeLikePattern(q)}%` + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 + const needle = `${escapeLikePattern(q)}%` const nameCond = or( like(textbooks.title, needle), like(textbooks.subject, needle), @@ -587,6 +691,7 @@ export const getTextbooksWithScopeRaw = async ( textbooks.updatedAt ) .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) + .limit(DEFAULT_LIMIT) return rows.map((r) => ({ id: r.id, @@ -646,6 +751,7 @@ export const getKnowledgePointOptionsRaw = async (): Promise ({ id: row.id, @@ -666,6 +772,11 @@ export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, { // ===== Prerequisite CRUD ===== +/** + * 创建知识点前置依赖关系(knowledgePointId → prerequisiteKpId)。 + * + * @param data - 前置依赖输入(knowledgePointId/prerequisiteKpId) + */ export async function createPrerequisite(data: CreatePrerequisiteInput): Promise { await db.insert(knowledgePointPrerequisites).values({ knowledgePointId: data.knowledgePointId, @@ -673,6 +784,11 @@ export async function createPrerequisite(data: CreatePrerequisiteInput): Promise }) } +/** + * 删除知识点前置依赖关系(按 knowledgePointId + prerequisiteKpId 联合主键)。 + * + * @param data - 待删除的前置依赖输入(knowledgePointId/prerequisiteKpId) + */ export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise { await db .delete(knowledgePointPrerequisites) @@ -686,7 +802,7 @@ export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise * 获取教材下所有知识点的前置依赖边列表。 * 用于循环检测。 */ -export async function getPrerequisiteEdgesForTextbook( +export async function getPrerequisiteEdgesForTextbookRaw( textbookId: string, ): Promise> { const rows = await db @@ -701,3 +817,90 @@ export async function getPrerequisiteEdgesForTextbook( return rows.map((r): [string, string] => [r.knowledgePointId, r.prerequisiteKpId]) } +export const getPrerequisiteEdgesForTextbook = cacheFn(getPrerequisiteEdgesForTextbookRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getPrerequisiteEdgesForTextbook"], +}) + +// --------------------------------------------------------------------------- +// Cross-module batch title/name resolvers +// --------------------------------------------------------------------------- + +/** + * 跨模块接口:按教材 ID 批量获取教材标题。 + * + * 供 lesson-preparation 等模块解析课案关联的教材名称,避免直接查询 textbooks 表。 + */ +export const getTextbookTitlesByIdsRaw = async ( + textbookIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(textbookIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: textbooks.id, title: textbooks.title }) + .from(textbooks) + .where(inArray(textbooks.id, uniqueIds)) + + for (const r of rows) result.set(r.id, r.title) + return result +} +export const getTextbookTitlesByIds = cacheFn(getTextbookTitlesByIdsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getTextbookTitlesByIds"], +}) + +/** + * 跨模块接口:按章节 ID 批量获取章节标题。 + * + * 供 lesson-preparation 等模块解析课案关联的章节名称,避免直接查询 chapters 表。 + */ +export const getChapterTitlesByIdsRaw = async ( + chapterIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(chapterIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: chapters.id, title: chapters.title }) + .from(chapters) + .where(inArray(chapters.id, uniqueIds)) + + for (const r of rows) result.set(r.id, r.title) + return result +} +export const getChapterTitlesByIds = cacheFn(getChapterTitlesByIdsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getChapterTitlesByIds"], +}) + +/** + * 跨模块接口:按知识点 ID 批量获取知识点名称。 + * + * 供 questions 模块解析题目关联的知识点名称,避免直接查询 knowledgePoints 表。 + */ +export const getKnowledgePointNamesByIdsRaw = async ( + knowledgePointIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: knowledgePoints.id, name: knowledgePoints.name }) + .from(knowledgePoints) + .where(inArray(knowledgePoints.id, uniqueIds)) + + for (const r of rows) result.set(r.id, r.name) + return result +} +export const getKnowledgePointNamesByIds = cacheFn(getKnowledgePointNamesByIdsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getKnowledgePointNamesByIds"], +}) diff --git a/src/modules/textbooks/hooks/use-kp-create.ts b/src/modules/textbooks/hooks/use-kp-create.ts index 4fe6739..20076af 100644 --- a/src/modules/textbooks/hooks/use-kp-create.ts +++ b/src/modules/textbooks/hooks/use-kp-create.ts @@ -1,7 +1,7 @@ "use client" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { createKnowledgePointAction } from "../actions" @@ -34,15 +34,15 @@ export function useKpCreate({ ) if (result.success) { - toast.success(t("action.kpCreateSuccess")) + notify.success(t("action.kpCreateSuccess")) onKpCreated?.() window.getSelection()?.removeAllRanges() return true } - toast.error(result.message || t("action.kpCreateFailed")) + notify.error(result.message || t("action.kpCreateFailed")) return false } catch { - toast.error(t("action.errorOccurred")) + notify.error(t("action.errorOccurred")) return false } } diff --git a/src/modules/textbooks/hooks/use-kp-delete.ts b/src/modules/textbooks/hooks/use-kp-delete.ts index b7e28ec..d52b7bb 100644 --- a/src/modules/textbooks/hooks/use-kp-delete.ts +++ b/src/modules/textbooks/hooks/use-kp-delete.ts @@ -1,7 +1,7 @@ "use client" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { deleteKnowledgePointAction } from "../actions" import type { useKpDialogState } from "./use-kp-dialog-state" @@ -40,15 +40,15 @@ export function useKpDelete({ try { const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId) if (result.success) { - toast.success(result.message) + notify.success(result.message) if (highlightedKpId === dialog.pendingDeleteKpId) { setHighlightedKpId(null) } } else { - toast.error(result.message) + notify.error(result.message) } } catch { - toast.error(t("action.deleteFailed")) + notify.error(t("action.deleteFailed")) } finally { dialog.setPendingDeleteKpId(null) } diff --git a/src/modules/textbooks/hooks/use-kp-update.ts b/src/modules/textbooks/hooks/use-kp-update.ts index dac477e..adab596 100644 --- a/src/modules/textbooks/hooks/use-kp-update.ts +++ b/src/modules/textbooks/hooks/use-kp-update.ts @@ -1,7 +1,7 @@ "use client" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { updateKnowledgePointAction } from "../actions" import type { useKpDialogState } from "./use-kp-dialog-state" @@ -27,14 +27,14 @@ export function useKpUpdate({ textbookId, dialog }: UseKpUpdateArgs) { try { const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) dialog.setEditKpDialogOpen(false) dialog.setEditingKp(null) } else { - toast.error(result.message) + notify.error(result.message) } } catch { - toast.error(t("action.updateFailedGeneric")) + notify.error(t("action.updateFailedGeneric")) } finally { dialog.setIsUpdatingKp(false) } diff --git a/src/modules/textbooks/types.ts b/src/modules/textbooks/types.ts index 7c12759..276ae84 100644 --- a/src/modules/textbooks/types.ts +++ b/src/modules/textbooks/types.ts @@ -6,6 +6,8 @@ // UpdateChapterContentInput, CreateKnowledgePointInput, UpdateKnowledgePointInput) // are defined in ./schema.ts alongside their Zod validation schemas. +import type { LinkObject, NodeObject } from "react-force-graph-2d"; + export type Textbook = { id: string; title: string; @@ -106,4 +108,26 @@ export interface KnowledgeGraphData { } /** 掌握度色彩等级 */ -export type MasteryLevel = "low" | "medium" | "high" | "unassessed" +export type MasteryLevel = "low" | "medium" | "high" | "unassessed"; + +// ===== 力导向图谱节点/边类型(从 force-graph.tsx 提取,供 lib/type-guards.ts 引用) ===== + +/** 力导向图谱节点(react-force-graph-2d NodeObject 的业务扩展) */ +export interface KpGraphNode extends NodeObject { + id: string; + name: string; + kp: KpWithRelations; + mastery: MasteryInfo | null; + /** 连接数(决定节点大小) */ + val: number; + /** 章节颜色 */ + chapterColor: string; +} + +/** 力导向图谱边(react-force-graph-2d LinkObject 的业务扩展) */ +export interface KpGraphLink extends LinkObject { + source: string | KpGraphNode; + target: string | KpGraphNode; + /** 边类型:parent(树归属)/ prerequisite(依赖) */ + edgeType: "parent" | "prerequisite"; +} diff --git a/src/modules/users/components/user-import-dialog.tsx b/src/modules/users/components/user-import-dialog.tsx index 5f2759a..c8ebe98 100644 --- a/src/modules/users/components/user-import-dialog.tsx +++ b/src/modules/users/components/user-import-dialog.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from "react" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Download, Upload, FileSpreadsheet, CheckCircle2, XCircle, Loader2 } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -63,9 +63,9 @@ export function UserImportDialog() { setDownloading(false) if (result.success && result.data) { downloadBase64File(result.data, result.message ?? "template.xlsx") - toast.success("模板已下载") + notify.success("模板已下载") } else { - toast.error(result.message ?? "下载失败") + notify.error(result.message ?? "下载失败") } } @@ -75,7 +75,7 @@ export function UserImportDialog() { const lowerName = file.name.toLowerCase() if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) { - toast.error("仅支持 .xlsx 和 .xls 文件") + notify.error("仅支持 .xlsx 和 .xls 文件") return } @@ -89,12 +89,12 @@ export function UserImportDialog() { }) const data = await res.json() if (!data.success) { - toast.error(data.message ?? "解析失败") + notify.error(data.message ?? "解析失败") return } const rows = data.sheets?.[0]?.rows ?? [] if (rows.length === 0) { - toast.error("文件中无数据") + notify.error("文件中无数据") return } setState({ @@ -103,9 +103,9 @@ export function UserImportDialog() { fileName: file.name, result: null, }) - toast.success(`已解析 ${rows.length} 行数据`) + notify.success(`已解析 ${rows.length} 行数据`) } catch { - toast.error("文件解析失败") + notify.error("文件解析失败") } } @@ -117,7 +117,7 @@ export function UserImportDialog() { if (fileInputRef.current?.files?.[0]) { formData.append("file", fileInputRef.current.files[0]) } else { - toast.error("请重新选择文件") + notify.error("请重新选择文件") setState((s) => ({ ...s, status: "preview" })) return } @@ -125,10 +125,10 @@ export function UserImportDialog() { const result = await importUsersAction(null, formData) if (result.success && result.data) { setState((s) => ({ ...s, status: "done", result: result.data! })) - toast.success(result.message ?? "导入完成") + notify.success(result.message ?? "导入完成") router.refresh() } else { - toast.error(result.message ?? "导入失败") + notify.error(result.message ?? "导入失败") setState((s) => ({ ...s, status: "preview" })) } }