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
This commit is contained in:
SpecialX
2026-07-07 16:22:07 +08:00
parent 783b8f5484
commit ebaf03107d
39 changed files with 655 additions and 1339 deletions

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -30,7 +30,7 @@ export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) {
const handlePreview = async () => { const handlePreview = async () => {
if (!classId) { if (!classId) {
toast.error("Please select a class") notify.error("Please select a class")
return return
} }
setLoading(true) setLoading(true)
@@ -40,12 +40,12 @@ export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) {
const res = await autoScheduleAction(null, formData) const res = await autoScheduleAction(null, formData)
if (res.success && res.data) { if (res.success && res.data) {
setResult(res.data) setResult(res.data)
toast.success(res.message) notify.success(res.message)
} else { } else {
toast.error(res.message || "Failed to generate schedule") notify.error(res.message || "Failed to generate schedule")
} }
} catch { } catch {
toast.error("Failed to generate schedule") notify.error("Failed to generate schedule")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -66,13 +66,13 @@ export function AutoSchedulePanel({ classes }: { classes: ClassOption[] }) {
})) }))
) )
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to apply schedule") notify.error(res.message || "Failed to apply schedule")
} }
} catch { } catch {
toast.error("Failed to apply schedule") notify.error("Failed to apply schedule")
} finally { } finally {
setApplying(false) setApplying(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -44,11 +44,11 @@ export function ScheduleChangeForm({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!classId) { if (!classId) {
toast.error("Please select a class") notify.error("Please select a class")
return return
} }
if (!reason.trim()) { if (!reason.trim()) {
toast.error("Reason is required") notify.error("Reason is required")
return return
} }
setSubmitting(true) setSubmitting(true)
@@ -64,7 +64,7 @@ export function ScheduleChangeForm({
const res = await requestScheduleChangeAction(null, formData) const res = await requestScheduleChangeAction(null, formData)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.refresh() router.refresh()
// Reset form // Reset form
setOriginalDate("") setOriginalDate("")
@@ -75,10 +75,10 @@ export function ScheduleChangeForm({
setOriginalTeacherId("") setOriginalTeacherId("")
setSubstituteTeacherId("") setSubstituteTeacherId("")
} else { } else {
toast.error(res.message || "Failed to submit request") notify.error(res.message || "Failed to submit request")
} }
} catch { } catch {
toast.error("Failed to submit request") notify.error("Failed to submit request")
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Check, X } from "lucide-react" import { Check, X } from "lucide-react"
@@ -52,14 +52,14 @@ export function ScheduleChangeList({ items, canApprove = false }: ScheduleChange
try { try {
const res = await approveScheduleChangeAction(approveId) const res = await approveScheduleChangeAction(approveId)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setApproveId(null) setApproveId(null)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to approve") notify.error(res.message || "Failed to approve")
} }
} catch { } catch {
toast.error("Failed to approve") notify.error("Failed to approve")
} finally { } finally {
setActing(false) setActing(false)
} }
@@ -71,15 +71,15 @@ export function ScheduleChangeList({ items, canApprove = false }: ScheduleChange
try { try {
const res = await rejectScheduleChangeAction(rejectId, rejectReason || undefined) const res = await rejectScheduleChangeAction(rejectId, rejectReason || undefined)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setRejectId(null) setRejectId(null)
setRejectReason("") setRejectReason("")
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to reject") notify.error(res.message || "Failed to reject")
} }
} catch { } catch {
toast.error("Failed to reject") notify.error("Failed to reject")
} finally { } finally {
setActing(false) setActing(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { AlertTriangle, CheckCircle2, RefreshCw } from "lucide-react" import { AlertTriangle, CheckCircle2, RefreshCw } from "lucide-react"
@@ -36,7 +36,7 @@ export function ScheduleConflictsView({ classes }: { classes: ClassOption[] }) {
const handleCheck = async () => { const handleCheck = async () => {
if (!classId) { if (!classId) {
toast.error("Please select a class") notify.error("Please select a class")
return return
} }
setLoading(true) setLoading(true)
@@ -45,15 +45,15 @@ export function ScheduleConflictsView({ classes }: { classes: ClassOption[] }) {
if (res.success && res.data) { if (res.success && res.data) {
setConflicts(res.data) setConflicts(res.data)
if (res.data.length === 0) { if (res.data.length === 0) {
toast.success("No conflicts detected") notify.success("No conflicts detected")
} else { } else {
toast.warning(`Found ${res.data.length} conflict(s)`) notify.warning(`Found ${res.data.length} conflict(s)`)
} }
} else { } else {
toast.error(res.message || "Failed to check conflicts") notify.error(res.message || "Failed to check conflicts")
} }
} catch { } catch {
toast.error("Failed to check conflicts") notify.error("Failed to check conflicts")
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -88,7 +88,7 @@ export function SchedulingRulesForm({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!classId) { if (!classId) {
toast.error("Please select a class") notify.error("Please select a class")
return return
} }
formData.set("classId", classId) formData.set("classId", classId)
@@ -103,10 +103,10 @@ export function SchedulingRulesForm({
const result = await saveSchedulingRulesAction(null, formData) const result = await saveSchedulingRulesAction(null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
router.refresh() router.refresh()
} else { } else {
toast.error(result.message || "Failed to save rules") notify.error(result.message || "Failed to save rules")
} }
} }

View File

@@ -13,46 +13,40 @@ import {
updateClassScheduleItemById, updateClassScheduleItemById,
deleteClassScheduleItemById, deleteClassScheduleItemById,
} from "./data-access" } from "./data-access"
import {
buildValidatedScheduleUpdate,
normalizeAndValidateCreateInput,
} from "./lib/schedule-validation"
import type { import type {
CreateClassScheduleItemInput, CreateClassScheduleItemInput,
UpdateClassScheduleItemInput, UpdateClassScheduleItemInput,
} from "./types" } from "./types"
const isTimeHHMM = (v: string): boolean => /^\d{2}:\d{2}$/.test(v)
/** /**
* Create a single classSchedule item. * Create a single classSchedule item.
* Ownership: the caller (teacher) must own the target class. * Ownership: the caller (teacher) must own the target class.
* DB write is delegated to the unified scheduling write entry point. * DB write is delegated to the unified scheduling write entry point.
*
* A-02 修复:字段校验(时间格式/星期范围/时间区间)已抽离至
* lib/schedule-validation.ts本函数仅保留 DB 相关的归属校验 + CRUD。
*/ */
export async function createClassScheduleItem( export async function createClassScheduleItem(
data: CreateClassScheduleItemInput, data: CreateClassScheduleItemInput,
): Promise<string> { ): Promise<string> {
const teacherId = await getTeacherIdForMutations() const teacherId = await getTeacherIdForMutations()
const classId = data.classId.trim() const normalized = normalizeAndValidateCreateInput(data)
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
if (!classId) throw new Error("Class is required") const owned = await verifyTeacherOwnsClass(normalized.classId, teacherId)
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)
if (!owned) throw new Error("Class not found") if (!owned) throw new Error("Class not found")
return insertClassScheduleItem({ return insertClassScheduleItem({
classId, classId: normalized.classId,
weekday, weekday: normalized.weekday,
startTime, startTime: normalized.startTime,
endTime, endTime: normalized.endTime,
course, course: normalized.course,
location, location: normalized.location,
}) })
} }
@@ -60,6 +54,9 @@ export async function createClassScheduleItem(
* Update a classSchedule item by id. * Update a classSchedule item by id.
* Ownership: the teacher must own the class associated with the schedule item * Ownership: the teacher must own the class associated with the schedule item
* (and the target class when classId is being changed). * (and the target class when classId is being changed).
*
* A-02 修复:字段校验与 update 对象构建已抽离至 lib/schedule-validation.ts
* 本函数仅保留 DB 读取existing 记录、归属校验DB 相关)+ CRUD 写入。
*/ */
export async function updateClassScheduleItem( export async function updateClassScheduleItem(
scheduleId: string, scheduleId: string,
@@ -85,49 +82,19 @@ export async function updateClassScheduleItem(
const ownedExisting = await verifyTeacherOwnsClass(existing.classId, teacherId) const ownedExisting = await verifyTeacherOwnsClass(existing.classId, teacherId)
if (!ownedExisting) throw new Error("Schedule item not found") if (!ownedExisting) throw new Error("Schedule item not found")
const update: Partial<typeof classSchedule.$inferSelect> = {} const { update, nextClassId } = buildValidatedScheduleUpdate(
{
if (typeof data.classId === "string") { classId: existing.classId,
const nextClassId = data.classId.trim() startTime: existing.startTime,
if (!nextClassId) throw new Error("Class is required") endTime: existing.endTime,
},
data,
)
// classId 变更时需校验目标班级归属DB 相关校验,保留在 data-access
if (nextClassId !== undefined) {
const ownedNext = await verifyTeacherOwnsClass(nextClassId, teacherId) const ownedNext = await verifyTeacherOwnsClass(nextClassId, teacherId)
if (!ownedNext) throw new Error("Class not found") 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 if (Object.keys(update).length === 0) return

View File

@@ -1,20 +1,23 @@
import "server-only" 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 { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { cacheFn } from "@/shared/lib/cache" import { cacheFn } from "@/shared/lib/cache"
import { import {
classes,
classSchedule, classSchedule,
classSubjectTeachers,
classrooms,
scheduleChanges, scheduleChanges,
schedulingRules, schedulingRules,
subjects,
users,
} from "@/shared/db/schema" } 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 { import type {
ScheduleChangeListItem, ScheduleChangeListItem,
@@ -50,7 +53,20 @@ export async function getSchedulingRulesRaw(classId?: string): Promise<Schedulin
} }
const rows = await db const rows = await db
.select() .select({
id: schedulingRules.id,
classId: schedulingRules.classId,
maxDailyHours: schedulingRules.maxDailyHours,
maxContinuousHours: schedulingRules.maxContinuousHours,
lunchBreakStart: schedulingRules.lunchBreakStart,
lunchBreakEnd: schedulingRules.lunchBreakEnd,
morningStart: schedulingRules.morningStart,
afternoonEnd: schedulingRules.afternoonEnd,
avoidBackToBack: schedulingRules.avoidBackToBack,
balancedSubjects: schedulingRules.balancedSubjects,
createdAt: schedulingRules.createdAt,
updatedAt: schedulingRules.updatedAt,
})
.from(schedulingRules) .from(schedulingRules)
.where(conditions.length > 0 ? and(...conditions) : undefined) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt)) .orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt))
@@ -66,7 +82,7 @@ export const getSchedulingRules = cacheFn(getSchedulingRulesRaw, {
export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<string> { export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<string> {
const [existing] = await db const [existing] = await db
.select() .select({ id: schedulingRules.id })
.from(schedulingRules) .from(schedulingRules)
.where(eq(schedulingRules.classId, data.classId)) .where(eq(schedulingRules.classId, data.classId))
.limit(1) .limit(1)
@@ -116,34 +132,30 @@ export async function getScheduleChangesRaw(
const rows = await db const rows = await db
.select({ .select({
change: scheduleChanges, change: scheduleChanges,
className: classes.name,
originalTeacherName: users.name,
}) })
.from(scheduleChanges) .from(scheduleChanges)
.innerJoin(classes, eq(classes.id, scheduleChanges.classId))
.leftJoin(users, eq(users.id, scheduleChanges.originalTeacherId))
.where(conditions.length > 0 ? and(...conditions) : undefined) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(scheduleChanges.createdAt)) .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( const userIds = Array.from(
new Set( new Set(
rows.flatMap((r) => [ rows.flatMap((r) => [
r.change.substituteTeacherId, r.change.substituteTeacherId,
r.change.approvedBy, r.change.approvedBy,
r.change.requestedBy, r.change.requestedBy,
r.change.originalTeacherId,
].filter((v): v is string => typeof v === "string" && v.length > 0)) ].filter((v): v is string => typeof v === "string" && v.length > 0))
) )
) )
const userMap = new Map<string, string>() const [classNameMap, userMap] = await Promise.all([
if (userIds.length > 0) { getClassNamesByIds(classIds),
const userRows = await db getUserNamesByIds(userIds),
.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")
}
return rows.map((r) => ({ return rows.map((r) => ({
id: r.change.id, id: r.change.id,
@@ -161,13 +173,15 @@ export async function getScheduleChangesRaw(
approvedBy: r.change.approvedBy ?? null, approvedBy: r.change.approvedBy ?? null,
createdAt: r.change.createdAt.toISOString(), createdAt: r.change.createdAt.toISOString(),
updatedAt: r.change.updatedAt.toISOString(), updatedAt: r.change.updatedAt.toISOString(),
className: r.className, className: classNameMap.get(r.change.classId) ?? "Unknown",
originalTeacherName: r.originalTeacherName ?? null, originalTeacherName: r.change.originalTeacherId
substituteTeacherName: r.change.substituteTeacherId ? userMap.get(r.change.originalTeacherId)?.name ?? null
? userMap.get(r.change.substituteTeacherId) ?? null
: null, : null,
requesterName: userMap.get(r.change.requestedBy) ?? "Unknown", substituteTeacherName: r.change.substituteTeacherId
approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy) ?? null : null, ? 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<SchedulingClassOption[]> { export async function getAdminClassesForSchedulingRaw(): Promise<SchedulingClassOption[]> {
return await db // 通过 classes 模块 data-access 获取班级列表,避免直接查询 classes 表
.select({ id: classes.id, name: classes.name, grade: classes.grade }) return await getClassesForScheduling()
.from(classes)
.orderBy(classes.grade, classes.name)
} }
export const getAdminClassesForScheduling = cacheFn(getAdminClassesForSchedulingRaw, { export const getAdminClassesForScheduling = cacheFn(getAdminClassesForSchedulingRaw, {
@@ -293,12 +305,14 @@ export const getAdminClassesForScheduling = cacheFn(getAdminClassesForScheduling
}) })
export async function getTeachersForSchedulingRaw(): Promise<SchedulingTeacherOption[]> { export async function getTeachersForSchedulingRaw(): Promise<SchedulingTeacherOption[]> {
return await db // 通过 classes 模块获取有任课的教师 ID再通过 users 模块解析教师详情,
.select({ id: users.id, name: users.name, email: users.email }) // 避免直接查询 users / classSubjectTeachers 表
.from(users) const teacherIds = await getDistinctTeacherIdsFromAssignments()
.innerJoin(classSubjectTeachers, eq(classSubjectTeachers.teacherId, users.id)) if (teacherIds.length === 0) return []
.groupBy(users.id, users.name, users.email) const teachers = await getTeachersByIds(teacherIds)
.orderBy(users.name) 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, { export const getTeachersForScheduling = cacheFn(getTeachersForSchedulingRaw, {
@@ -308,10 +322,9 @@ export const getTeachersForScheduling = cacheFn(getTeachersForSchedulingRaw, {
}) })
export async function getClassroomsForSchedulingRaw(): Promise<SchedulingClassroomOption[]> { export async function getClassroomsForSchedulingRaw(): Promise<SchedulingClassroomOption[]> {
return await db // 通过 school 模块 data-access 获取教室列表,避免直接查询 classrooms 表
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building }) const rooms = await getSchoolClassroomsForScheduling()
.from(classrooms) return rooms.map((c) => ({ id: c.id, name: c.name, building: c.building }))
.orderBy(classrooms.name)
} }
export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw, { export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw, {
@@ -323,15 +336,19 @@ export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw,
export async function getClassSubjectsForSchedulingRaw( export async function getClassSubjectsForSchedulingRaw(
classId: string classId: string
): Promise<SchedulingClassSubject[]> { ): Promise<SchedulingClassSubject[]> {
return await db // 通过 classes 模块获取班级科目分配,再通过 school 模块解析科目名称,
.select({ // 避免直接查询 classSubjectTeachers / subjects 表
subjectId: subjects.id, const assignments = await getClassSubjectAssignmentsByClassId(classId)
subjectName: subjects.name, if (assignments.length === 0) return []
teacherId: classSubjectTeachers.teacherId,
}) const subjectIds = Array.from(new Set(assignments.map((a) => a.subjectId)))
.from(classSubjectTeachers) const subjectNameMap = await getSubjectNameMapByIds(subjectIds)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(eq(classSubjectTeachers.classId, classId)) return assignments.map((a) => ({
subjectId: a.subjectId,
subjectName: subjectNameMap.get(a.subjectId) ?? "Unknown",
teacherId: a.teacherId,
}))
} }
export const getClassSubjectsForScheduling = cacheFn(getClassSubjectsForSchedulingRaw, { export const getClassSubjectsForScheduling = cacheFn(getClassSubjectsForSchedulingRaw, {

View File

@@ -2,7 +2,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-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 { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -56,14 +56,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
formData.set("isActive", createActive ? "true" : "false") formData.set("isActive", createActive ? "true" : "false")
const res = await createAcademicYearAction(undefined, formData) const res = await createAcademicYearAction(undefined, formData)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setCreateOpen(false) setCreateOpen(false)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("academicYear.delete.title")) notify.error(res.message || t("academicYear.delete.title"))
} }
} catch { } catch {
toast.error(t("academicYear.delete.title")) notify.error(t("academicYear.delete.title"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -76,14 +76,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
formData.set("isActive", editActive ? "true" : "false") formData.set("isActive", editActive ? "true" : "false")
const res = await updateAcademicYearAction(editItem.id, undefined, formData) const res = await updateAcademicYearAction(editItem.id, undefined, formData)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setEditItem(null) setEditItem(null)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("academicYear.delete.title")) notify.error(res.message || t("academicYear.delete.title"))
} }
} catch { } catch {
toast.error(t("academicYear.delete.title")) notify.error(t("academicYear.delete.title"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -95,14 +95,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
try { try {
const res = await deleteAcademicYearAction(deleteItem.id) const res = await deleteAcademicYearAction(deleteItem.id)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setDeleteItem(null) setDeleteItem(null)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("academicYear.delete.title")) notify.error(res.message || t("academicYear.delete.title"))
} }
} catch { } catch {
toast.error(t("academicYear.delete.title")) notify.error(t("academicYear.delete.title"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-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 { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -49,14 +49,14 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
try { try {
const res = await createDepartmentAction(undefined, formData) const res = await createDepartmentAction(undefined, formData)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setCreateOpen(false) setCreateOpen(false)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("departments.delete.title")) notify.error(res.message || t("departments.delete.title"))
} }
} catch { } catch {
toast.error(t("departments.delete.title")) notify.error(t("departments.delete.title"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -68,14 +68,14 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
try { try {
const res = await updateDepartmentAction(editItem.id, undefined, formData) const res = await updateDepartmentAction(editItem.id, undefined, formData)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setEditItem(null) setEditItem(null)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("departments.delete.title")) notify.error(res.message || t("departments.delete.title"))
} }
} catch { } catch {
toast.error(t("departments.delete.title")) notify.error(t("departments.delete.title"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -87,14 +87,14 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
try { try {
const res = await deleteDepartmentAction(deleteItem.id) const res = await deleteDepartmentAction(deleteItem.id)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setDeleteItem(null) setDeleteItem(null)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("departments.delete.title")) notify.error(res.message || t("departments.delete.title"))
} }
} catch { } catch {
toast.error(t("departments.delete.title")) notify.error(t("departments.delete.title"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { GradeListItem, SchoolListItem, StaffOption } from "../types" import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
@@ -162,11 +162,11 @@ export function GradeFormDialog({
const handleSubmit = (): void => { const handleSubmit = (): void => {
const result = validateForm(state, editItem?.id) const result = validateForm(state, editItem?.id)
if (!result.ok) { 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 return
} }
if (isEdit && !isDirty) { if (isEdit && !isDirty) {
toast.message(t("grades.validation.noChanges")) notify.message(t("grades.validation.noChanges"))
return return
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { import {
@@ -98,13 +98,13 @@ export function AdminSettingsView(): React.ReactElement {
try { try {
const result = await saveAdminSystemSettingsAction(values) const result = await saveAdminSystemSettingsAction(values)
if (result.success) { if (result.success) {
toast.success(t("saveSuccess")) notify.success(t("saveSuccess"))
setLoadedValues(values) setLoadedValues(values)
} else { } else {
toast.error(result.message || t("saveFailure")) notify.error(result.message || t("saveFailure"))
} }
} catch { } catch {
toast.error(t("saveFailure")) notify.error(t("saveFailure"))
} finally { } finally {
setSaving(false) setSaving(false)
} }

View File

@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Loader2, Save, Sparkles } from "lucide-react" import { Loader2, Save, Sparkles } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -112,7 +112,7 @@ export function AiProviderSettingsCard({
try { try {
const result = await getAiProviderSummaries() const result = await getAiProviderSummaries()
if (!result.success || !result.data) { if (!result.success || !result.data) {
toast.error(result.message ?? t("loadFailure")) notify.error(result.message ?? t("loadFailure"))
return return
} }
const rows = result.data const rows = result.data
@@ -136,7 +136,7 @@ export function AiProviderSettingsCard({
}) })
} }
} catch { } catch {
toast.error(t("loadFailure")) notify.error(t("loadFailure"))
} }
}) })
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t]) }, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
@@ -178,7 +178,7 @@ export function AiProviderSettingsCard({
const apiKey = values.apiKey?.trim() const apiKey = values.apiKey?.trim()
const isLocalProvider = values.provider === "ollama" const isLocalProvider = values.provider === "ollama"
if (!apiKey && !values.id?.trim() && !isLocalProvider) { if (!apiKey && !values.id?.trim() && !isLocalProvider) {
toast.error(t("needKey")) notify.error(t("needKey"))
return return
} }
setTestStatus("testing") setTestStatus("testing")
@@ -196,10 +196,10 @@ export function AiProviderSettingsCard({
if (result.success) { if (result.success) {
setTestStatus("passed") setTestStatus("passed")
setLastTestedSignature(buildSignature(values)) setLastTestedSignature(buildSignature(values))
toast.success(result.message ?? t("testSuccess")) notify.success(result.message ?? t("testSuccess"))
} else { } else {
setTestStatus("failed") 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 onSubmit = (values: AiProviderFormValues) => {
const signature = buildSignature(values) const signature = buildSignature(values)
if (testStatus !== "passed" || signature !== lastTestedSignature) { if (testStatus !== "passed" || signature !== lastTestedSignature) {
toast.error(t("needTest")) notify.error(t("needTest"))
return return
} }
startTransition(async () => { startTransition(async () => {
@@ -222,12 +222,12 @@ export function AiProviderSettingsCard({
} }
const result = await upsertAiProviderAction(payload) const result = await upsertAiProviderAction(payload)
if (result.success) { if (result.success) {
toast.success(result.message ?? t("saveSuccess")) notify.success(result.message ?? t("saveSuccess"))
setTestStatus("idle") setTestStatus("idle")
setLastTestedSignature("") setLastTestedSignature("")
const summariesResult = await getAiProviderSummaries() const summariesResult = await getAiProviderSummaries()
if (!summariesResult.success || !summariesResult.data) { if (!summariesResult.success || !summariesResult.data) {
toast.error(summariesResult.message ?? t("loadFailure")) notify.error(summariesResult.message ?? t("loadFailure"))
return return
} }
const rows = summariesResult.data const rows = summariesResult.data
@@ -248,7 +248,7 @@ export function AiProviderSettingsCard({
}) })
} }
} else { } else {
toast.error(result.message ?? t("saveFailure")) notify.error(result.message ?? t("saveFailure"))
} }
}) })
} }
@@ -256,13 +256,13 @@ export function AiProviderSettingsCard({
const handleDelete = () => { const handleDelete = () => {
const id = form.getValues("id") const id = form.getValues("id")
if (!id?.trim()) { if (!id?.trim()) {
toast.error(t("deleteNeedSelect")) notify.error(t("deleteNeedSelect"))
return return
} }
startTransition(async () => { startTransition(async () => {
const result = await deleteAiProviderAction({ id: id.trim() }) const result = await deleteAiProviderAction({ id: id.trim() })
if (result.success) { if (result.success) {
toast.success(result.message ?? t("deleteSuccess")) notify.success(result.message ?? t("deleteSuccess"))
const summariesResult = await getAiProviderSummaries() const summariesResult = await getAiProviderSummaries()
if (summariesResult.success && summariesResult.data) { if (summariesResult.success && summariesResult.data) {
const rows = summariesResult.data const rows = summariesResult.data
@@ -287,7 +287,7 @@ export function AiProviderSettingsCard({
resetToNew() resetToNew()
} }
} else { } else {
toast.error(result.message ?? t("deleteFailure")) notify.error(result.message ?? t("deleteFailure"))
} }
}) })
} }

View File

@@ -3,10 +3,11 @@
import * as React from "react" import * as React from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Loader2, Trash2, Upload } from "lucide-react" 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 { removeUserAvatarAction, updateUserAvatarAction } from "@/modules/settings/actions-avatar"
import type { FileUploadResult } from "@/modules/files/types" 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 { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -67,7 +68,7 @@ export function AvatarUpload({
const error = validateFile(file) const error = validateFile(file)
if (error) { if (error) {
toast.error(error) notify.error(error)
return return
} }
@@ -84,11 +85,17 @@ export function AvatarUpload({
}) })
if (!response.ok) { 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") 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 更新用户头像 // 调用 Server Action 更新用户头像
const updateResult = await updateUserAvatarAction(result.url) const updateResult = await updateUserAvatarAction(result.url)
@@ -98,10 +105,10 @@ export function AvatarUpload({
setPreviewUrl(result.url) setPreviewUrl(result.url)
onUpdated?.(result.url) onUpdated?.(result.url)
toast.success(t("uploadSuccess")) notify.success(t("uploadSuccess"))
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t("uploadFailure") const message = error instanceof Error ? error.message : t("uploadFailure")
toast.error(message) notify.error(message)
} finally { } finally {
setUploading(false) setUploading(false)
if (inputRef.current) { if (inputRef.current) {
@@ -119,10 +126,10 @@ export function AvatarUpload({
} }
setPreviewUrl(null) setPreviewUrl(null)
onUpdated?.(null) onUpdated?.(null)
toast.success(t("removeSuccess")) notify.success(t("removeSuccess"))
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t("removeFailure") const message = error instanceof Error ? error.message : t("removeFailure")
toast.error(message) notify.error(message)
} finally { } finally {
setRemoving(false) setRemoving(false)
} }

View File

@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Loader2, Save } from "lucide-react" import { Loader2, Save } from "lucide-react"
import { import {
@@ -42,7 +42,7 @@ export function BrandConfigCard(): React.ReactElement {
setConfig(res.data) setConfig(res.data)
} }
} catch { } catch {
if (!cancelled) toast.error(t("loadFailed")) if (!cancelled) notify.error(t("loadFailed"))
} finally { } finally {
if (!cancelled) setIsLoading(false) if (!cancelled) setIsLoading(false)
} }
@@ -65,12 +65,12 @@ export function BrandConfigCard(): React.ReactElement {
const res = await saveBrandConfigAction({ success: false }, formData) const res = await saveBrandConfigAction({ success: false }, formData)
if (res.success) { if (res.success) {
toast.success(t("saveSuccess")) notify.success(t("saveSuccess"))
} else { } else {
toast.error(res.message ?? t("saveFailed")) notify.error(res.message ?? t("saveFailed"))
} }
} catch { } catch {
toast.error(t("saveFailed")) notify.error(t("saveFailed"))
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { useTransition } from "react" import { useTransition } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon, Send } from "lucide-react" 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 { sendTestNotificationAction } from "@/modules/settings/actions-notifications"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -136,12 +136,12 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
quietHoursEnd: quietHours.quietHoursEnd || null, quietHoursEnd: quietHours.quietHoursEnd || null,
}) })
if (result.success) { if (result.success) {
toast.success(t("success")) notify.success(t("success"))
} else { } else {
toast.error(result.message || t("failure")) notify.error(result.message || t("failure"))
} }
} catch { } catch {
toast.error(t("failure")) notify.error(t("failure"))
} }
}) })
} }
@@ -151,12 +151,12 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
try { try {
const result = await sendTestNotificationAction({ channel }) const result = await sendTestNotificationAction({ channel })
if (result.success) { if (result.success) {
toast.success(t("testSuccess")) notify.success(t("testSuccess"))
} else { } else {
toast.error(result.message || t("testFailure")) notify.error(result.message || t("testFailure"))
} }
} catch { } catch {
toast.error(t("testFailure")) notify.error(t("testFailure"))
} finally { } finally {
setTestingChannel(null) setTestingChannel(null)
} }

View File

@@ -4,7 +4,7 @@ import { useActionState, useEffect, useMemo, useRef, useState } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react" 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 { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -62,10 +62,10 @@ export function PasswordChangeForm() {
useEffect(() => { useEffect(() => {
if (state?.success) { if (state?.success) {
toast.success(state.message ?? t("success")) notify.success(state.message ?? t("success"))
formRef.current?.reset() formRef.current?.reset()
} else if (state?.message) { } else if (state?.message) {
toast.error(state.message) notify.error(state.message)
} }
}, [state, t]) }, [state, t])

View File

@@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { Loader2, Save } from "lucide-react" import { Loader2, Save } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" 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, age: ageNum !== undefined && !Number.isNaN(ageNum) ? ageNum : undefined,
}) })
if (result.success) { if (result.success) {
toast.success(t("success")) notify.success(t("success"))
} else { } else {
toast.error(result.message || t("failure")) notify.error(result.message || t("failure"))
} }
} catch { } catch {
toast.error(t("failure")) notify.error(t("failure"))
} }
}) })
} }

View File

@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useLocale, useTranslations } from "next-intl" import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { import {
LogIn, LogIn,
LogOut, LogOut,
@@ -65,16 +65,16 @@ export function SecurityRecentLoginsSection({
const result = await revokeAllOtherSessionsAction() const result = await revokeAllOtherSessionsAction()
if (result.success && result.data) { if (result.success && result.data) {
if (result.data.revokedCount > 0) { if (result.data.revokedCount > 0) {
toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount })) notify.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount }))
} else { } else {
toast.info(t("recentLogins.revokeSuccessEmpty")) notify.info(t("recentLogins.revokeSuccessEmpty"))
} }
await onRevoked() await onRevoked()
} else { } else {
toast.error(result.message || t("recentLogins.revokeFailure")) notify.error(result.message || t("recentLogins.revokeFailure"))
} }
} catch { } catch {
toast.error(t("recentLogins.revokeFailure")) notify.error(t("recentLogins.revokeFailure"))
} finally { } finally {
setRevoking(false) setRevoking(false)
} }

View File

@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { import {
Smartphone, Smartphone,
Loader2, Loader2,
@@ -98,11 +98,11 @@ export function SecurityTwoFactorSection({
setSetupData(result.data) setSetupData(result.data)
setSetupStep("qr") setSetupStep("qr")
} else { } else {
toast.error(result.message || t("twoFactor.setupFailure")) notify.error(result.message || t("twoFactor.setupFailure"))
setEnableDialogOpen(false) setEnableDialogOpen(false)
} }
} catch { } catch {
toast.error(t("twoFactor.setupFailure")) notify.error(t("twoFactor.setupFailure"))
setEnableDialogOpen(false) setEnableDialogOpen(false)
} finally { } finally {
setSetupLoading(false) setSetupLoading(false)
@@ -118,12 +118,12 @@ export function SecurityTwoFactorSection({
setBackupCodes(result.data.backupCodes) setBackupCodes(result.data.backupCodes)
onStatusChange(result.data.status) onStatusChange(result.data.status)
setSetupStep("backup") setSetupStep("backup")
toast.success(t("twoFactor.enableSuccess")) notify.success(t("twoFactor.enableSuccess"))
} else { } else {
toast.error(result.message || t("twoFactor.invalidCode")) notify.error(result.message || t("twoFactor.invalidCode"))
} }
} catch { } catch {
toast.error(t("twoFactor.verifyFailure")) notify.error(t("twoFactor.verifyFailure"))
} finally { } finally {
setSetupLoading(false) setSetupLoading(false)
} }
@@ -158,12 +158,12 @@ export function SecurityTwoFactorSection({
onStatusChange(result.data) onStatusChange(result.data)
setDisableDialogOpen(false) setDisableDialogOpen(false)
setDisableCode("") setDisableCode("")
toast.success(t("twoFactor.disableSuccess")) notify.success(t("twoFactor.disableSuccess"))
} else { } else {
toast.error(result.message || t("twoFactor.invalidCode")) notify.error(result.message || t("twoFactor.invalidCode"))
} }
} catch { } catch {
toast.error(t("twoFactor.disableFailure")) notify.error(t("twoFactor.disableFailure"))
} finally { } finally {
setDisableLoading(false) setDisableLoading(false)
} }
@@ -180,12 +180,12 @@ export function SecurityTwoFactorSection({
setRegenBackupCodes(result.data.backupCodes) setRegenBackupCodes(result.data.backupCodes)
onStatusChange(result.data.status) onStatusChange(result.data.status)
setRegenCode("") setRegenCode("")
toast.success(t("twoFactor.regenerateSuccess")) notify.success(t("twoFactor.regenerateSuccess"))
} else { } else {
toast.error(result.message || t("twoFactor.invalidCode")) notify.error(result.message || t("twoFactor.invalidCode"))
} }
} catch { } catch {
toast.error(t("twoFactor.regenerateFailure")) notify.error(t("twoFactor.regenerateFailure"))
} finally { } finally {
setRegenLoading(false) setRegenLoading(false)
} }

View File

@@ -16,6 +16,7 @@ import type {
GetStandardsParams, GetStandardsParams,
} from "./types"; } from "./types";
import type { StandardLevel } from "../lesson-preparation/lib/type-guards"; 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, code: row.code,
title: row.title, title: row.title,
description: row.description ?? undefined, description: row.description ?? undefined,
level: row.level as StandardLevel, level: toStandardLevel(row.level),
parentId: row.parentId ?? undefined, parentId: row.parentId ?? undefined,
subjectId: row.subjectId ?? undefined, subjectId: row.subjectId ?? undefined,
gradeId: row.gradeId ?? undefined, gradeId: row.gradeId ?? undefined,

View File

@@ -3,7 +3,7 @@
import Link from "next/link" import Link from "next/link"
import { memo, useState, useTransition } from "react" import { memo, useState, useTransition } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
@@ -118,15 +118,15 @@ export function StudentCoursesView({
try { try {
const res = await joinClassByInvitationCodeAction(null, formData) const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) { if (res.success) {
toast.success(res.message || t("coursesView.joinedSuccess")) notify.success(res.message || t("coursesView.joinedSuccess"))
setCode("") setCode("")
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("coursesView.joinFailed")) notify.error(res.message || t("coursesView.joinFailed"))
} }
} catch (err) { } catch (err) {
console.error("[joinClass] failed:", err) console.error("[joinClass] failed:", err)
toast.error(t("coursesView.joinFailed")) notify.error(t("coursesView.joinFailed"))
} }
}) })
} }

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-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 { useTranslations } from "next-intl"
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core" import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable" import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
@@ -245,13 +245,13 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
try { try {
const result = await reorderChaptersAction(String(active.id), newIndex, activeParentId, textbookId) const result = await reorderChaptersAction(String(active.id), newIndex, activeParentId, textbookId)
if (result.success) { if (result.success) {
toast.success(t("dialog.chapter.orderUpdated")) notify.success(t("dialog.chapter.orderUpdated"))
} else { } else {
toast.error(result.message || t("dialog.chapter.orderUpdateFailed")) notify.error(result.message || t("dialog.chapter.orderUpdateFailed"))
} }
} catch (e) { } catch (e) {
console.error("Failed to reorder chapters", 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 { try {
const res = await deleteChapterAction(deleteTarget.id, textbookId) const res = await deleteChapterAction(deleteTarget.id, textbookId)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
setShowDeleteDialog(false) setShowDeleteDialog(false)
setDeleteTarget(null) setDeleteTarget(null)
} else { } else {
toast.error(res.message) notify.error(res.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to delete chapter", e) console.error("Failed to delete chapter", e)
toast.error(t("reader.deleteFailed")) notify.error(t("reader.deleteFailed"))
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }
@@ -278,7 +278,7 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
const handleDeleteRequest = (chapter: Chapter) => { const handleDeleteRequest = (chapter: Chapter) => {
if (chapter.children && chapter.children.length > 0) { if (chapter.children && chapter.children.length > 0) {
toast.error(t("dialog.chapter.cannotDeleteWithSubchapters")) notify.error(t("dialog.chapter.cannotDeleteWithSubchapters"))
return return
} }
setDeleteTarget(chapter) setDeleteTarget(chapter)

View File

@@ -17,7 +17,7 @@ import {
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { createChapterAction } from "../actions" import { createChapterAction } from "../actions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
function SubmitButton() { function SubmitButton() {
const { pending } = useFormStatus() const { pending } = useFormStatus()
@@ -53,14 +53,14 @@ export function CreateChapterDialog({
try { try {
const result = await createChapterAction(textbookId, parentId, null, formData) const result = await createChapterAction(textbookId, parentId, null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
setOpen(false) setOpen(false)
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to create chapter", e) console.error("Failed to create chapter", e)
toast.error(t("reader.createFailed")) notify.error(t("reader.createFailed"))
} }
} }

View File

@@ -28,10 +28,12 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import type { import type {
GraphViewMode, GraphViewMode,
KnowledgeGraphData, KnowledgeGraphData,
KpWithRelations, KpGraphNode,
KpGraphLink,
MasteryInfo, MasteryInfo,
MasteryLevel, MasteryLevel,
} from "../types" } from "../types"
import { isKpGraphNode, isKpGraphLink } from "../lib/type-guards"
/** 章节颜色调色板柔和、Obsidian 风格) */ /** 章节颜色调色板柔和、Obsidian 风格) */
const CHAPTER_COLORS = [ const CHAPTER_COLORS = [
@@ -95,25 +97,8 @@ function getMasteryLevel(mastery: MasteryInfo | null): MasteryLevel {
return "high" return "high"
} }
/** 力导向图谱节点 */ /** 力导向图谱节点 KpGraphNode / 边 KpGraphLink 类型定义已迁移至 ../types
interface KpGraphNode extends NodeObject { * 供 lib/type-guards.ts 类型守卫引用,避免组件文件被守卫模块反向依赖。 */
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"
}
interface ForceGraphProps { interface ForceGraphProps {
data: KnowledgeGraphData data: KnowledgeGraphData
@@ -288,7 +273,9 @@ function ForceGraphInner({
// 节点 Canvas 绘制Obsidian 风格) // 节点 Canvas 绘制Obsidian 风格)
const nodeCanvasObject = useCallback( const nodeCanvasObject = useCallback(
(node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const kpNode = node as KpGraphNode // 类型守卫收窄 NodeObject → KpGraphNode替代 as 断言)
if (!isKpGraphNode(node)) return
const kpNode = node
const highlightState = getNodeHighlightState( const highlightState = getNodeHighlightState(
kpNode.id, kpNode.id,
searchText, searchText,
@@ -361,9 +348,15 @@ function ForceGraphInner({
// 边绘制Obsidian 风格:极细半透明线,颜色区分类型) // 边绘制Obsidian 风格:极细半透明线,颜色区分类型)
const linkCanvasObject = useCallback( const linkCanvasObject = useCallback(
(link: LinkObject, ctx: CanvasRenderingContext2D) => { (link: LinkObject, ctx: CanvasRenderingContext2D) => {
const kpLink = link as KpGraphLink // 类型守卫收窄 LinkObject → KpGraphLink替代 as 断言)
const source = kpLink.source as KpGraphNode if (!isKpGraphLink(link)) return
const target = kpLink.target as KpGraphNode 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 if (!source.x || !source.y || !target.x || !target.y) return
// 高亮逻辑:有焦点时仅高亮含邻居节点的边 // 高亮逻辑:有焦点时仅高亮含邻居节点的边
@@ -388,8 +381,9 @@ function ForceGraphInner({
const handleNodeClick = useCallback( const handleNodeClick = useCallback(
(node: NodeObject) => { (node: NodeObject) => {
const kpNode = node as KpGraphNode // 类型守卫收窄 NodeObject → KpGraphNode替代 as 断言)
onSelectKp(kpNode.id === selectedKpId ? null : kpNode.id) if (!isKpGraphNode(node)) return
onSelectKp(node.id === selectedKpId ? null : node.id)
}, },
[onSelectKp, selectedKpId], [onSelectKp, selectedKpId],
) )
@@ -399,7 +393,9 @@ function ForceGraphInner({
}, [onSelectKp]) }, [onSelectKp])
const handleNodeHover = useCallback((node: NodeObject | null) => { 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) { if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default" containerRef.current.style.cursor = node ? "pointer" : "default"
} }

View File

@@ -5,6 +5,7 @@ import { Handle, Position, type NodeProps } from "@xyflow/react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import type { GraphNodeData, MasteryLevel, KpWithRelations } from "../types" import type { GraphNodeData, MasteryLevel, KpWithRelations } from "../types"
import { isGraphNodeData, isKpWithRelations } from "../lib/type-guards"
import { NODE_WIDTH } from "../graph-layout" import { NODE_WIDTH } from "../graph-layout"
/** 根据掌握度计算色彩等级 */ /** 根据掌握度计算色彩等级 */
@@ -30,22 +31,24 @@ const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
} }
/** /**
* 类型守卫:从 React Flow node.dataRecord<string, unknown>)安全提取图谱节点数据。 * 从 React Flow node.dataRecord<string, unknown>)安全提取图谱节点数据。
* 替代 as unknown as 双重断言,提供运行时安全 * 使用类型守卫替代 as 断言,运行时校验失败返回 null组件渲染 null 而非崩溃)
*/ */
function extractNodeData(data: Record<string, unknown>): { function extractNodeData(data: Record<string, unknown>): {
kp: KpWithRelations kp: KpWithRelations
graphData?: GraphNodeData graphData?: GraphNodeData
} { } | null {
const kp = data.kp as KpWithRelations if (!isKpWithRelations(data.kp)) return null
const graphData = data.graphData as GraphNodeData | undefined const graphData = isGraphNodeData(data.graphData) ? data.graphData : undefined
return { kp, graphData } return { kp: data.kp, graphData }
} }
function GraphKpNodeComponent(props: NodeProps) { function GraphKpNodeComponent(props: NodeProps) {
const t = useTranslations("textbooks") const t = useTranslations("textbooks")
const { data, selected } = props 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 mastery = graphData?.mastery ?? null
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null) const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery" const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"

View File

@@ -4,6 +4,7 @@ import { memo } from "react"
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react" import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import type { GraphEdgeData } from "../types" import type { GraphEdgeData } from "../types"
import { isGraphEdgeData } from "../lib/type-guards"
function GraphPrerequisiteEdgeComponent({ function GraphPrerequisiteEdgeComponent({
id, id,
@@ -24,8 +25,9 @@ function GraphPrerequisiteEdgeComponent({
targetPosition, targetPosition,
}) })
// EdgeProps.data 类型为 Record<string, unknown>,经 unknown 安全转换读取 GraphEdgeData // 类型守卫收窄 EdgeProps.dataRecord<string, unknown> | undefined GraphEdgeData
const edgeData = data as unknown as GraphEdgeData | undefined // 替代 as unknown as 双重断言,提供运行时安全
const edgeData: GraphEdgeData | undefined = isGraphEdgeData(data) ? data : undefined
const isHighlighted = edgeData?.isHighlighted ?? false const isHighlighted = edgeData?.isHighlighted ?? false
return ( return (

View File

@@ -20,6 +20,7 @@ import "@xyflow/react/dist/style.css"
import type { GraphLayoutMode, GraphViewMode, KnowledgeGraphData } from "../types" import type { GraphLayoutMode, GraphViewMode, KnowledgeGraphData } from "../types"
import type { GraphLayoutNodeData } from "../graph-layout" import type { GraphLayoutNodeData } from "../graph-layout"
import { isGraphLayoutNodeData } from "../lib/type-guards"
import { GraphKpNode } from "./graph-kp-node" import { GraphKpNode } from "./graph-kp-node"
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge" import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
import { ForceKnowledgeGraph } from "./force-graph" import { ForceKnowledgeGraph } from "./force-graph"
@@ -78,10 +79,11 @@ export function KnowledgeGraphNode({
<MiniMap <MiniMap
className="!bg-background !border !rounded-lg" className="!bg-background !border !rounded-lg"
nodeColor={(node) => { nodeColor={(node) => {
// 安全的类型收窄node.dataRecord<string, unknown> // 类型守卫收窄 node.dataRecord<string, unknown>)→ GraphLayoutNodeData
// GraphLayoutNodeData 有索引签名 [key: string]: unknown是 Record 的子类型 const nodeData: GraphLayoutNodeData | undefined = isGraphLayoutNodeData(node.data)
const nodeData = node.data as GraphLayoutNodeData ? node.data
return nodeData.graphData?.chapterColor ?? "hsl(var(--muted-foreground))" : undefined
return nodeData?.graphData?.chapterColor ?? "hsl(var(--muted-foreground))"
}} }}
/> />
</ReactFlow> </ReactFlow>

View File

@@ -33,7 +33,7 @@ import { cn, formatDate } from "@/shared/lib/utils"
import type { Textbook } from "../types" import type { Textbook } from "../types"
import { getSubjectColor, getSubjectLabelKey, getGradeLabelKey } from "../constants" import { getSubjectColor, getSubjectLabelKey, getGradeLabelKey } from "../constants"
import { deleteTextbookAction } from "../actions" import { deleteTextbookAction } from "../actions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
interface TextbookCardProps { interface TextbookCardProps {
textbook: Textbook textbook: Textbook
@@ -54,14 +54,14 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
try { try {
const result = await deleteTextbookAction(textbook.id) const result = await deleteTextbookAction(textbook.id)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
router.refresh() router.refresh()
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to delete textbook", e) console.error("Failed to delete textbook", e)
toast.error(t("reader.deleteFailed")) notify.error(t("reader.deleteFailed"))
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
setShowDeleteDialog(false) setShowDeleteDialog(false)

View File

@@ -15,7 +15,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { createTextbookAction } from "../actions" import { createTextbookAction } from "../actions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { TextbookFormFields } from "./textbook-form-fields" import { TextbookFormFields } from "./textbook-form-fields"
function SubmitButton() { function SubmitButton() {
@@ -36,14 +36,14 @@ export function TextbookFormDialog() {
try { try {
const result = await createTextbookAction(null, formData) const result = await createTextbookAction(null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
setOpen(false) setOpen(false)
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to create textbook", e) console.error("Failed to create textbook", e)
toast.error(t("reader.createFailed")) notify.error(t("reader.createFailed"))
} }
} }

View File

@@ -3,7 +3,7 @@
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react" import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs" import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import type { Chapter, KnowledgePoint } from "../types" import type { Chapter, KnowledgePoint } from "../types"
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions" import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
@@ -162,15 +162,15 @@ export function TextbookReader({
const result = await updateChapterContentAction(selectedId, editContent, textbookId) const result = await updateChapterContentAction(selectedId, editContent, textbookId)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
setIsEditing(false) setIsEditing(false)
setLocalContent(editContent) setLocalContent(editContent)
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to save chapter content", e) console.error("Failed to save chapter content", e)
toast.error(t("reader.saveFailed")) notify.error(t("reader.saveFailed"))
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }

View File

@@ -25,7 +25,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog" } from "@/shared/components/ui/alert-dialog"
import { updateTextbookAction, deleteTextbookAction } from "../actions" import { updateTextbookAction, deleteTextbookAction } from "../actions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import type { Textbook } from "../types" import type { Textbook } from "../types"
import { TextbookFormFields } from "./textbook-form-fields" import { TextbookFormFields } from "./textbook-form-fields"
@@ -48,14 +48,14 @@ export function TextbookSettingsDialog({ textbook, trigger, redirectAfterDelete
try { try {
const result = await updateTextbookAction(textbook.id, null, formData) const result = await updateTextbookAction(textbook.id, null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
setOpen(false) setOpen(false)
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to update textbook", e) console.error("Failed to update textbook", e)
toast.error(t("reader.updateFailed")) notify.error(t("reader.updateFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -69,14 +69,14 @@ export function TextbookSettingsDialog({ textbook, trigger, redirectAfterDelete
const result = await deleteTextbookAction(textbook.id) const result = await deleteTextbookAction(textbook.id)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
router.push(redirectAfterDelete) router.push(redirectAfterDelete)
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch (e) { } catch (e) {
console.error("Failed to delete textbook", e) console.error("Failed to delete textbook", e)
toast.error(t("reader.deleteFailed")) notify.error(t("reader.deleteFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -1,6 +1,6 @@
import "server-only" 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 { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -8,9 +8,10 @@ import {
chapters, chapters,
knowledgePoints, knowledgePoints,
knowledgePointPrerequisites, knowledgePointPrerequisites,
questionsToKnowledgePoints,
knowledgePointMastery,
} from "@/shared/db/schema" } 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" import type { KpWithRelations, MasteryInfo } from "./types"
/** /**
@@ -42,20 +43,9 @@ export const getKnowledgePointsWithRelationsRaw = async (
const kpIds = kpRows.map((r) => r.id) const kpIds = kpRows.map((r) => r.id)
// 2. 查询关联题目数(批量聚合 // 2. 查询关联题目数(跨模块批量聚合,通过 questions 模块 data-access 调用,
const questionCountRows = await db // 避免直接查询 questionsToKnowledgePoints 表)
.select({ const questionCountMap = await getQuestionCountByKpIds(kpIds)
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
count: count(),
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
.groupBy(questionsToKnowledgePoints.knowledgePointId)
const questionCountMap = new Map<string, number>()
for (const r of questionCountRows) {
questionCountMap.set(r.knowledgePointId, Number(r.count))
}
// 3. 查询前置依赖(批量,仅查询属于当前教材知识点的依赖) // 3. 查询前置依赖(批量,仅查询属于当前教材知识点的依赖)
// 双向过滤knowledgePointId 和 prerequisiteKpId 都必须在当前教材的知识点集合内 // 双向过滤knowledgePointId 和 prerequisiteKpId 都必须在当前教材的知识点集合内
@@ -99,37 +89,20 @@ export const getKnowledgePointsWithRelations = cacheFn(getKnowledgePointsWithRel
/** /**
* 获取学生在某教材下所有知识点的掌握度。 * 获取学生在某教材下所有知识点的掌握度。
*
* 通过 textbooks 模块 data-access 获取教材下知识点 ID 集合,
* 再调用 diagnostic 模块 data-access 按知识点 ID 查询掌握度,
* 避免直接查询 knowledgePointMastery 表。
*/ */
export const getStudentKpMasteryRaw = async ( export const getStudentKpMasteryRaw = async (
studentId: string, studentId: string,
textbookId: string, textbookId: string,
): Promise<Map<string, MasteryInfo>> => { ): Promise<Map<string, MasteryInfo>> => {
const rows = await db const kps = await getKnowledgePointsByTextbookId(textbookId)
.select({ const kpIds = kps.map((kp) => kp.id)
knowledgePointId: knowledgePointMastery.knowledgePointId, if (kpIds.length === 0) return new Map()
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 map = new Map<string, MasteryInfo>() return await getStudentMasteryByKpIds(studentId, kpIds)
for (const r of rows) {
map.set(r.knowledgePointId, {
masteryLevel: Number(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt,
})
}
return map
} }
export const getStudentKpMastery = cacheFn(getStudentKpMasteryRaw, { export const getStudentKpMastery = cacheFn(getStudentKpMasteryRaw, {
tags: ["textbooks"], tags: ["textbooks"],
@@ -140,6 +113,10 @@ export const getStudentKpMastery = cacheFn(getStudentKpMasteryRaw, {
/** /**
* 获取班级(教师所带班级的所有学生)在某教材下知识点的平均掌握度。 * 获取班级(教师所带班级的所有学生)在某教材下知识点的平均掌握度。
* *
* 通过 textbooks 模块 data-access 获取教材下知识点 ID 集合,
* 再调用 diagnostic 模块 data-access 按知识点 ID 聚合掌握度,
* 避免直接查询 knowledgePointMastery 表。
*
* @param studentIds 班级学生 ID 列表 * @param studentIds 班级学生 ID 列表
* @param textbookId 教材 ID * @param textbookId 教材 ID
*/ */
@@ -149,33 +126,11 @@ export const getClassKpMasteryRaw = async (
): Promise<Map<string, MasteryInfo>> => { ): Promise<Map<string, MasteryInfo>> => {
if (studentIds.length === 0) return new Map() if (studentIds.length === 0) return new Map()
const rows = await db const kps = await getKnowledgePointsByTextbookId(textbookId)
.select({ const kpIds = kps.map((kp) => kp.id)
knowledgePointId: knowledgePointMastery.knowledgePointId, if (kpIds.length === 0) return new Map()
avgMastery: sql<number>`AVG(${knowledgePointMastery.masteryLevel})`,
totalQuestions: sql<number>`SUM(${knowledgePointMastery.totalQuestions})`,
correctQuestions: sql<number>`SUM(${knowledgePointMastery.correctQuestions})`,
lastAssessedAt: sql<Date>`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 map = new Map<string, MasteryInfo>() return await getClassMasteryByKpIds(studentIds, kpIds)
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
} }
export const getClassKpMastery = cacheFn(getClassKpMasteryRaw, { export const getClassKpMastery = cacheFn(getClassKpMasteryRaw, {
tags: ["textbooks"], tags: ["textbooks"],

View File

@@ -31,6 +31,9 @@ import {
export { buildChapterTree, findChapterById, normalizeOptional, sortChapters } export { buildChapterTree, findChapterById, normalizeOptional, sortChapters }
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
const DEFAULT_LIMIT = 1000
/** /**
* 数据范围过滤参数。 * 数据范围过滤参数。
* 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。 * 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。
@@ -45,7 +48,8 @@ export const getTextbooksRaw = async (query?: string, subject?: string, grade?:
const q = query?.trim() const q = query?.trim()
if (q) { if (q) {
const needle = `%${escapeLikePattern(q)}%` // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描
const needle = `${escapeLikePattern(q)}%`
const nameCond = or( const nameCond = or(
like(textbooks.title, needle), like(textbooks.title, needle),
like(textbooks.subject, needle), like(textbooks.subject, needle),
@@ -77,6 +81,7 @@ export const getTextbooksRaw = async (query?: string, subject?: string, grade?:
.where(conditions.length ? and(...conditions) : undefined) .where(conditions.length ? and(...conditions) : undefined)
.groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt) .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)) .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
.limit(DEFAULT_LIMIT)
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
@@ -167,6 +172,12 @@ export const getChaptersByTextbookId = cacheFn(getChaptersByTextbookIdRaw, {
keyParts: ["textbooks", "getChaptersByTextbookId"], keyParts: ["textbooks", "getChaptersByTextbookId"],
}) })
/**
* 创建教材。
*
* @param data - 教材输入title/subject/grade/publisher
* @returns 新创建的教材对象chaptersCount 为 0
*/
export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> { export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> {
const id = createId() const id = createId()
const now = new Date() const now = new Date()
@@ -189,6 +200,13 @@ export async function createTextbook(data: CreateTextbookInput): Promise<Textboo
} }
} }
/**
* 更新教材字段title/subject/grade/publisher
*
* @param data - 更新输入(必须含 id
* @returns 更新后的教材对象
* @throws {NotFoundError} 当教材不存在时抛出
*/
export async function updateTextbook(data: UpdateTextbookInput): Promise<Textbook> { export async function updateTextbook(data: UpdateTextbookInput): Promise<Textbook> {
await db await db
.update(textbooks) .update(textbooks)
@@ -205,10 +223,21 @@ export async function updateTextbook(data: UpdateTextbookInput): Promise<Textboo
return updated return updated
} }
/**
* 删除教材DB 级联删除关联章节,但章节下的知识点需应用层显式清理)。
*
* @param id - 教材 ID
*/
export async function deleteTextbook(id: string): Promise<void> { export async function deleteTextbook(id: string): Promise<void> {
await db.delete(textbooks).where(eq(textbooks.id, id)) 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<Chapter> { export async function createChapter(data: CreateChapterInput): Promise<Chapter> {
const id = createId() const id = createId()
const now = new Date() const now = new Date()
@@ -239,6 +268,13 @@ export async function createChapter(data: CreateChapterInput): Promise<Chapter>
} }
} }
/**
* 更新章节正文 content 字段,并返回更新后的章节对象。
*
* @param data - 更新输入chapterId + content
* @returns 更新后的章节对象children 为空数组)
* @throws {NotFoundError} 当章节不存在时抛出
*/
export async function updateChapterContent(data: UpdateChapterContentInput): Promise<Chapter> { export async function updateChapterContent(data: UpdateChapterContentInput): Promise<Chapter> {
await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId)) 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<void> { export async function deleteChapter(id: string): Promise<void> {
const [target] = await db const [target] = await db
.select({ id: chapters.id, textbookId: chapters.textbookId }) .select({ id: chapters.id, textbookId: chapters.textbookId })
@@ -332,6 +377,12 @@ export async function deleteChapter(id: string): Promise<void> {
}) })
} }
/**
* 获取指定章节下的知识点列表,按 order 与 name 升序。
*
* @param chapterId - 章节 ID
* @returns 知识点数组
*/
export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise<KnowledgePoint[]> => { export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise<KnowledgePoint[]> => {
const rows = await db const rows = await db
.select({ .select({
@@ -363,6 +414,13 @@ export const getKnowledgePointsByChapterId = cacheFn(getKnowledgePointsByChapter
keyParts: ["textbooks", "getKnowledgePointsByChapterId"], keyParts: ["textbooks", "getKnowledgePointsByChapterId"],
}) })
/**
* 获取指定教材下的全部知识点(通过 chapters 表 JOIN 过滤),按章节 order、
* 知识点 order、知识点 name 升序。
*
* @param textbookId - 教材 ID
* @returns 知识点数组
*/
export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise<KnowledgePoint[]> => { export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise<KnowledgePoint[]> => {
const rows = await db const rows = await db
.select({ .select({
@@ -395,6 +453,11 @@ export const getKnowledgePointsByTextbookId = cacheFn(getKnowledgePointsByTextbo
keyParts: ["textbooks", "getKnowledgePointsByTextbookId"], keyParts: ["textbooks", "getKnowledgePointsByTextbookId"],
}) })
/**
* 创建知识点level/order 默认 0需调用方按需更新
*
* @param data - 知识点输入name/description?/anchorText?/chapterId/parentId?
*/
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> { export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> {
await db.insert(knowledgePoints).values({ await db.insert(knowledgePoints).values({
id: createId(), 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<void> { export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise<void> {
await db await db
.update(knowledgePoints) .update(knowledgePoints)
@@ -419,10 +487,26 @@ export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Pro
.where(eq(knowledgePoints.id, data.id)) .where(eq(knowledgePoints.id, data.id))
} }
/**
* 按 ID 删除知识点。
*
* @param id - 待删除的知识点 ID
*/
export async function deleteKnowledgePoint(id: string): Promise<void> { export async function deleteKnowledgePoint(id: string): Promise<void> {
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)) 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<void> { export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise<void> {
const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1) const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1)
if (!target) throw new NotFoundError("章节") if (!target) throw new NotFoundError("章节")
@@ -442,18 +526,22 @@ export async function reorderChapters(chapterId: string, newIndex: number, paren
currentSiblings.splice(newIndex, 0, target) currentSiblings.splice(newIndex, 0, target)
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
for (let i = 0; i < currentSiblings.length; i++) { // 批量并行 UPDATE 替代原先串行 await 循环,单事务保证原子性。
const ch = currentSiblings[i] // 仅对需要变更的行发起 UPDATEorder 变化或 parentId 变化)。
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) { await Promise.all(
await tx currentSiblings.map((ch, i) => {
.update(chapters) if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
.set({ return tx
order: i, .update(chapters)
parentId: ch.id === chapterId ? parentId : ch.parentId .set({
}) order: i,
.where(eq(chapters.id, ch.id)) 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 chapterCount: number
} }
/**
* 获取教材仪表盘统计(教材总数与章节总数)。
*
* @returns 包含 textbookCount / chapterCount 的统计对象
*/
export const getTextbooksDashboardStatsRaw = async (): Promise<TextbooksDashboardStats> => { export const getTextbooksDashboardStatsRaw = async (): Promise<TextbooksDashboardStats> => {
const [textbookCountRow, chapterCountRow] = await Promise.all([ const [textbookCountRow, chapterCountRow] = await Promise.all([
db.select({ value: count() }).from(textbooks), db.select({ value: count() }).from(textbooks),
@@ -489,7 +582,7 @@ export const getTextbooksDashboardStats = cacheFn(getTextbooksDashboardStatsRaw,
* *
* @returns true 表示归属一致 * @returns true 表示归属一致
*/ */
export async function verifyChapterBelongsToTextbook( export async function verifyChapterBelongsToTextbookRaw(
chapterId: string, chapterId: string,
textbookId: string textbookId: string
): Promise<boolean> { ): Promise<boolean> {
@@ -502,13 +595,18 @@ export async function verifyChapterBelongsToTextbook(
if (!row) return false if (!row) return false
return row.textbookId === textbookId return row.textbookId === textbookId
} }
export const verifyChapterBelongsToTextbook = cacheFn(verifyChapterBelongsToTextbookRaw, {
tags: ["textbooks"],
ttl: 60,
keyParts: ["textbooks", "verifyChapterBelongsToTextbook"],
})
/** /**
* 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。 * 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。
* *
* 用于 Server Action 二次校验,防止越权操作其他教材的知识点。 * 用于 Server Action 二次校验,防止越权操作其他教材的知识点。
*/ */
export async function verifyKnowledgePointBelongsToTextbook( export async function verifyKnowledgePointBelongsToTextbookRaw(
kpId: string, kpId: string,
textbookId: string textbookId: string
): Promise<boolean> { ): Promise<boolean> {
@@ -522,6 +620,11 @@ export async function verifyKnowledgePointBelongsToTextbook(
if (!row) return false if (!row) return false
return row.textbookId === textbookId return row.textbookId === textbookId
} }
export const verifyKnowledgePointBelongsToTextbook = cacheFn(verifyKnowledgePointBelongsToTextbookRaw, {
tags: ["textbooks"],
ttl: 60,
keyParts: ["textbooks", "verifyKnowledgePointBelongsToTextbook"],
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 带 scope 的查询P1-1 数据范围过滤) // 带 scope 的查询P1-1 数据范围过滤)
@@ -542,7 +645,8 @@ export const getTextbooksWithScopeRaw = async (
const q = query?.trim() const q = query?.trim()
if (q) { if (q) {
const needle = `%${escapeLikePattern(q)}%` // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描
const needle = `${escapeLikePattern(q)}%`
const nameCond = or( const nameCond = or(
like(textbooks.title, needle), like(textbooks.title, needle),
like(textbooks.subject, needle), like(textbooks.subject, needle),
@@ -587,6 +691,7 @@ export const getTextbooksWithScopeRaw = async (
textbooks.updatedAt textbooks.updatedAt
) )
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
.limit(DEFAULT_LIMIT)
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
@@ -646,6 +751,7 @@ export const getKnowledgePointOptionsRaw = async (): Promise<KnowledgePointOptio
asc(knowledgePoints.order), asc(knowledgePoints.order),
asc(knowledgePoints.name) asc(knowledgePoints.name)
) )
.limit(DEFAULT_LIMIT)
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
@@ -666,6 +772,11 @@ export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, {
// ===== Prerequisite CRUD ===== // ===== Prerequisite CRUD =====
/**
* 创建知识点前置依赖关系knowledgePointId → prerequisiteKpId
*
* @param data - 前置依赖输入knowledgePointId/prerequisiteKpId
*/
export async function createPrerequisite(data: CreatePrerequisiteInput): Promise<void> { export async function createPrerequisite(data: CreatePrerequisiteInput): Promise<void> {
await db.insert(knowledgePointPrerequisites).values({ await db.insert(knowledgePointPrerequisites).values({
knowledgePointId: data.knowledgePointId, 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<void> { export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise<void> {
await db await db
.delete(knowledgePointPrerequisites) .delete(knowledgePointPrerequisites)
@@ -686,7 +802,7 @@ export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise
* 获取教材下所有知识点的前置依赖边列表。 * 获取教材下所有知识点的前置依赖边列表。
* 用于循环检测。 * 用于循环检测。
*/ */
export async function getPrerequisiteEdgesForTextbook( export async function getPrerequisiteEdgesForTextbookRaw(
textbookId: string, textbookId: string,
): Promise<Array<[string, string]>> { ): Promise<Array<[string, string]>> {
const rows = await db const rows = await db
@@ -701,3 +817,90 @@ export async function getPrerequisiteEdgesForTextbook(
return rows.map((r): [string, string] => [r.knowledgePointId, r.prerequisiteKpId]) 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<Map<string, string>> => {
const result = new Map<string, string>()
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<Map<string, string>> => {
const result = new Map<string, string>()
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<Map<string, string>> => {
const result = new Map<string, string>()
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"],
})

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { createKnowledgePointAction } from "../actions" import { createKnowledgePointAction } from "../actions"
@@ -34,15 +34,15 @@ export function useKpCreate({
) )
if (result.success) { if (result.success) {
toast.success(t("action.kpCreateSuccess")) notify.success(t("action.kpCreateSuccess"))
onKpCreated?.() onKpCreated?.()
window.getSelection()?.removeAllRanges() window.getSelection()?.removeAllRanges()
return true return true
} }
toast.error(result.message || t("action.kpCreateFailed")) notify.error(result.message || t("action.kpCreateFailed"))
return false return false
} catch { } catch {
toast.error(t("action.errorOccurred")) notify.error(t("action.errorOccurred"))
return false return false
} }
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { deleteKnowledgePointAction } from "../actions" import { deleteKnowledgePointAction } from "../actions"
import type { useKpDialogState } from "./use-kp-dialog-state" import type { useKpDialogState } from "./use-kp-dialog-state"
@@ -40,15 +40,15 @@ export function useKpDelete({
try { try {
const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId) const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
if (highlightedKpId === dialog.pendingDeleteKpId) { if (highlightedKpId === dialog.pendingDeleteKpId) {
setHighlightedKpId(null) setHighlightedKpId(null)
} }
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch { } catch {
toast.error(t("action.deleteFailed")) notify.error(t("action.deleteFailed"))
} finally { } finally {
dialog.setPendingDeleteKpId(null) dialog.setPendingDeleteKpId(null)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { updateKnowledgePointAction } from "../actions" import { updateKnowledgePointAction } from "../actions"
import type { useKpDialogState } from "./use-kp-dialog-state" import type { useKpDialogState } from "./use-kp-dialog-state"
@@ -27,14 +27,14 @@ export function useKpUpdate({ textbookId, dialog }: UseKpUpdateArgs) {
try { try {
const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData) const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
dialog.setEditKpDialogOpen(false) dialog.setEditKpDialogOpen(false)
dialog.setEditingKp(null) dialog.setEditingKp(null)
} else { } else {
toast.error(result.message) notify.error(result.message)
} }
} catch { } catch {
toast.error(t("action.updateFailedGeneric")) notify.error(t("action.updateFailedGeneric"))
} finally { } finally {
dialog.setIsUpdatingKp(false) dialog.setIsUpdatingKp(false)
} }

View File

@@ -6,6 +6,8 @@
// UpdateChapterContentInput, CreateKnowledgePointInput, UpdateKnowledgePointInput) // UpdateChapterContentInput, CreateKnowledgePointInput, UpdateKnowledgePointInput)
// are defined in ./schema.ts alongside their Zod validation schemas. // are defined in ./schema.ts alongside their Zod validation schemas.
import type { LinkObject, NodeObject } from "react-force-graph-2d";
export type Textbook = { export type Textbook = {
id: string; id: string;
title: 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";
}

View File

@@ -2,7 +2,7 @@
import { useState, useRef } from "react" import { useState, useRef } from "react"
import { useRouter } from "next/navigation" 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 { Download, Upload, FileSpreadsheet, CheckCircle2, XCircle, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -63,9 +63,9 @@ export function UserImportDialog() {
setDownloading(false) setDownloading(false)
if (result.success && result.data) { if (result.success && result.data) {
downloadBase64File(result.data, result.message ?? "template.xlsx") downloadBase64File(result.data, result.message ?? "template.xlsx")
toast.success("模板已下载") notify.success("模板已下载")
} else { } else {
toast.error(result.message ?? "下载失败") notify.error(result.message ?? "下载失败")
} }
} }
@@ -75,7 +75,7 @@ export function UserImportDialog() {
const lowerName = file.name.toLowerCase() const lowerName = file.name.toLowerCase()
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) { if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
toast.error("仅支持 .xlsx 和 .xls 文件") notify.error("仅支持 .xlsx 和 .xls 文件")
return return
} }
@@ -89,12 +89,12 @@ export function UserImportDialog() {
}) })
const data = await res.json() const data = await res.json()
if (!data.success) { if (!data.success) {
toast.error(data.message ?? "解析失败") notify.error(data.message ?? "解析失败")
return return
} }
const rows = data.sheets?.[0]?.rows ?? [] const rows = data.sheets?.[0]?.rows ?? []
if (rows.length === 0) { if (rows.length === 0) {
toast.error("文件中无数据") notify.error("文件中无数据")
return return
} }
setState({ setState({
@@ -103,9 +103,9 @@ export function UserImportDialog() {
fileName: file.name, fileName: file.name,
result: null, result: null,
}) })
toast.success(`已解析 ${rows.length} 行数据`) notify.success(`已解析 ${rows.length} 行数据`)
} catch { } catch {
toast.error("文件解析失败") notify.error("文件解析失败")
} }
} }
@@ -117,7 +117,7 @@ export function UserImportDialog() {
if (fileInputRef.current?.files?.[0]) { if (fileInputRef.current?.files?.[0]) {
formData.append("file", fileInputRef.current.files[0]) formData.append("file", fileInputRef.current.files[0])
} else { } else {
toast.error("请重新选择文件") notify.error("请重新选择文件")
setState((s) => ({ ...s, status: "preview" })) setState((s) => ({ ...s, status: "preview" }))
return return
} }
@@ -125,10 +125,10 @@ export function UserImportDialog() {
const result = await importUsersAction(null, formData) const result = await importUsersAction(null, formData)
if (result.success && result.data) { if (result.success && result.data) {
setState((s) => ({ ...s, status: "done", result: result.data! })) setState((s) => ({ ...s, status: "done", result: result.data! }))
toast.success(result.message ?? "导入完成") notify.success(result.message ?? "导入完成")
router.refresh() router.refresh()
} else { } else {
toast.error(result.message ?? "导入失败") notify.error(result.message ?? "导入失败")
setState((s) => ({ ...s, status: "preview" })) setState((s) => ({ ...s, status: "preview" }))
} }
} }