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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.data(Record<string, unknown>)安全提取图谱节点数据。
|
* 从 React Flow node.data(Record<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"
|
||||||
|
|||||||
@@ -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.data(Record<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 (
|
||||||
|
|||||||
@@ -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.data 是 Record<string, unknown>,
|
// 类型守卫收窄 node.data(Record<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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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]
|
// 仅对需要变更的行发起 UPDATE(order 变化或 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"],
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user