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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -3,10 +3,11 @@
import * as React from "react"
import { useTranslations } from "next-intl"
import { Loader2, Trash2, Upload } from "lucide-react"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { removeUserAvatarAction, updateUserAvatarAction } from "@/modules/settings/actions-avatar"
import type { FileUploadResult } from "@/modules/files/types"
import { isFileUploadResult, isMessageBody } from "@/modules/settings/lib/type-guards"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Button } from "@/shared/components/ui/button"
@@ -67,7 +68,7 @@ export function AvatarUpload({
const error = validateFile(file)
if (error) {
toast.error(error)
notify.error(error)
return
}
@@ -84,11 +85,17 @@ export function AvatarUpload({
})
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { message?: string }
const raw: unknown = await response.json().catch(() => ({}))
// HTTP 响应不可信,做字段校验
const body = isMessageBody(raw) ? raw : {}
throw new Error(body.message || "Upload failed")
}
const result = (await response.json()) as FileUploadResult
const rawResult: unknown = await response.json()
if (!isFileUploadResult(rawResult)) {
throw new Error("Upload failed: invalid response")
}
const result: FileUploadResult = rawResult
// 调用 Server Action 更新用户头像
const updateResult = await updateUserAvatarAction(result.url)
@@ -98,10 +105,10 @@ export function AvatarUpload({
setPreviewUrl(result.url)
onUpdated?.(result.url)
toast.success(t("uploadSuccess"))
notify.success(t("uploadSuccess"))
} catch (error) {
const message = error instanceof Error ? error.message : t("uploadFailure")
toast.error(message)
notify.error(message)
} finally {
setUploading(false)
if (inputRef.current) {
@@ -119,10 +126,10 @@ export function AvatarUpload({
}
setPreviewUrl(null)
onUpdated?.(null)
toast.success(t("removeSuccess"))
notify.success(t("removeSuccess"))
} catch (error) {
const message = error instanceof Error ? error.message : t("removeFailure")
toast.error(message)
notify.error(message)
} finally {
setRemoving(false)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import type {
GetStandardsParams,
} from "./types";
import type { StandardLevel } from "../lesson-preparation/lib/type-guards";
import { toStandardLevel } from "./lib/type-guards";
/**
* 查询课标列表(可按层级/学科/年级过滤,可选返回树形结构)
@@ -253,7 +254,7 @@ function mapRowToStandard(row: typeof standards.$inferSelect): Standard {
code: row.code,
title: row.title,
description: row.description ?? undefined,
level: row.level as StandardLevel,
level: toStandardLevel(row.level),
parentId: row.parentId ?? undefined,
subjectId: row.subjectId ?? undefined,
gradeId: row.gradeId ?? undefined,

View File

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

View File

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

View File

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

View File

@@ -28,10 +28,12 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import type {
GraphViewMode,
KnowledgeGraphData,
KpWithRelations,
KpGraphNode,
KpGraphLink,
MasteryInfo,
MasteryLevel,
} from "../types"
import { isKpGraphNode, isKpGraphLink } from "../lib/type-guards"
/** 章节颜色调色板柔和、Obsidian 风格) */
const CHAPTER_COLORS = [
@@ -95,25 +97,8 @@ function getMasteryLevel(mastery: MasteryInfo | null): MasteryLevel {
return "high"
}
/** 力导向图谱节点 */
interface KpGraphNode extends NodeObject {
id: string
name: string
kp: KpWithRelations
mastery: MasteryInfo | null
/** 连接数(决定节点大小) */
val: number
/** 章节颜色 */
chapterColor: string
}
/** 力导向图谱边 */
interface KpGraphLink {
source: string | KpGraphNode
target: string | KpGraphNode
/** 边类型parent树归属/ prerequisite依赖 */
edgeType: "parent" | "prerequisite"
}
/** 力导向图谱节点 KpGraphNode / 边 KpGraphLink 类型定义已迁移至 ../types
* 供 lib/type-guards.ts 类型守卫引用,避免组件文件被守卫模块反向依赖。 */
interface ForceGraphProps {
data: KnowledgeGraphData
@@ -288,7 +273,9 @@ function ForceGraphInner({
// 节点 Canvas 绘制Obsidian 风格)
const nodeCanvasObject = useCallback(
(node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const kpNode = node as KpGraphNode
// 类型守卫收窄 NodeObject → KpGraphNode替代 as 断言)
if (!isKpGraphNode(node)) return
const kpNode = node
const highlightState = getNodeHighlightState(
kpNode.id,
searchText,
@@ -361,9 +348,15 @@ function ForceGraphInner({
// 边绘制Obsidian 风格:极细半透明线,颜色区分类型)
const linkCanvasObject = useCallback(
(link: LinkObject, ctx: CanvasRenderingContext2D) => {
const kpLink = link as KpGraphLink
const source = kpLink.source as KpGraphNode
const target = kpLink.target as KpGraphNode
// 类型守卫收窄 LinkObject → KpGraphLink替代 as 断言)
if (!isKpGraphLink(link)) return
const kpLink = link
// react-force-graph-2d 模拟后 source/target 会被库内部从 string id 替换为节点对象引用
const source = kpLink.source
const target = kpLink.target
if (typeof source === "string" || typeof target === "string") return
// 运行时再次校验 source/target 确为业务 KpGraphNode防止库内部替换为非业务节点
if (!isKpGraphNode(source) || !isKpGraphNode(target)) return
if (!source.x || !source.y || !target.x || !target.y) return
// 高亮逻辑:有焦点时仅高亮含邻居节点的边
@@ -388,8 +381,9 @@ function ForceGraphInner({
const handleNodeClick = useCallback(
(node: NodeObject) => {
const kpNode = node as KpGraphNode
onSelectKp(kpNode.id === selectedKpId ? null : kpNode.id)
// 类型守卫收窄 NodeObject → KpGraphNode替代 as 断言)
if (!isKpGraphNode(node)) return
onSelectKp(node.id === selectedKpId ? null : node.id)
},
[onSelectKp, selectedKpId],
)
@@ -399,7 +393,9 @@ function ForceGraphInner({
}, [onSelectKp])
const handleNodeHover = useCallback((node: NodeObject | null) => {
setHoveredKpId(node ? (node as KpGraphNode).id : null)
// 类型守卫收窄 NodeObject → KpGraphNode替代 as 断言)
const kpId = node && isKpGraphNode(node) ? node.id : null
setHoveredKpId(kpId)
if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@
// UpdateChapterContentInput, CreateKnowledgePointInput, UpdateKnowledgePointInput)
// are defined in ./schema.ts alongside their Zod validation schemas.
import type { LinkObject, NodeObject } from "react-force-graph-2d";
export type Textbook = {
id: string;
title: string;
@@ -106,4 +108,26 @@ export interface KnowledgeGraphData {
}
/** 掌握度色彩等级 */
export type MasteryLevel = "low" | "medium" | "high" | "unassessed"
export type MasteryLevel = "low" | "medium" | "high" | "unassessed";
// ===== 力导向图谱节点/边类型(从 force-graph.tsx 提取,供 lib/type-guards.ts 引用) =====
/** 力导向图谱节点react-force-graph-2d NodeObject 的业务扩展) */
export interface KpGraphNode extends NodeObject {
id: string;
name: string;
kp: KpWithRelations;
mastery: MasteryInfo | null;
/** 连接数(决定节点大小) */
val: number;
/** 章节颜色 */
chapterColor: string;
}
/** 力导向图谱边react-force-graph-2d LinkObject 的业务扩展) */
export interface KpGraphLink extends LinkObject {
source: string | KpGraphNode;
target: string | KpGraphNode;
/** 边类型parent树归属/ prerequisite依赖 */
edgeType: "parent" | "prerequisite";
}

View File

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