feat(modules-classes): update classes, course-plans, dashboard, diagnostic, elective, error-book
- classes: update actions-shared, admin-classes-view, class-detail, class-invitation-manager, grade-classes-view, my-classes-grid, schedule dialogs, students-table, data-access-admin, data-access-students, data-access-teacher, data-access - course-plans: update course-plan-detail, course-plan-form, course-plan-item-editor, template-picker-dialog, data-access - dashboard: update dashboard-time-range-filter, use-dashboard-preferences, use-dashboard-realtime - diagnostic: update class-diagnostic-view, report-list, data-access - elective: update elective-course-form, elective-course-list, student-selection-view, data-access-operations, data-access-selections, data-access - error-book: update add-error-book-dialog, error-book-detail-dialog, review-buttons
This commit is contained in:
@@ -2,12 +2,10 @@ import { getTranslations } from "next-intl/server"
|
||||
|
||||
import type { AuthContext } from "@/shared/types/permissions"
|
||||
import type { ClassSubject } from "./types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import { ValidationError } from "@/shared/lib/action-utils"
|
||||
|
||||
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||
|
||||
export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v)
|
||||
// G3-036 去重:isClassSubject 已提取至 lib/admin-class-mappers,此处 re-export 保持向后兼容
|
||||
export { isClassSubject } from "./lib/admin-class-mappers"
|
||||
import { isClassSubject } from "./lib/admin-class-mappers"
|
||||
|
||||
export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
n >= 1 && n <= 7 && Number.isInteger(n)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -43,14 +43,14 @@ export function AdminClassesClient({
|
||||
try {
|
||||
const res = await createAdminClassAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
data.setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
notify.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedCreate"))
|
||||
notify.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
@@ -62,14 +62,14 @@ export function AdminClassesClient({
|
||||
try {
|
||||
const res = await updateAdminClassAction(data.editItem.id, undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
data.setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
notify.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedUpdate"))
|
||||
notify.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
@@ -81,14 +81,14 @@ export function AdminClassesClient({
|
||||
try {
|
||||
const res = await deleteAdminClassAction(data.deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
data.setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedDelete"))
|
||||
notify.error(res.message || t("list.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedDelete"))
|
||||
notify.error(t("list.failedDelete"))
|
||||
} finally {
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -46,14 +46,14 @@ export function EditClassDialog({
|
||||
try {
|
||||
const res = await updateTeacherClassAction(classId, null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
notify.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedUpdate"))
|
||||
notify.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { Copy, Plus, Ban, Clock, Hash } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -85,9 +85,9 @@ export function ClassInvitationManager({
|
||||
const handleCopy = async (code: string): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
toast.success(t("copied"))
|
||||
notify.success(t("copied"))
|
||||
} catch {
|
||||
toast.error(t("copy"))
|
||||
notify.error(t("copy"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
|
||||
formData.set("note", note)
|
||||
const result = await createClassInvitationCodeAction(null, formData)
|
||||
if (result.success && result.data) {
|
||||
toast.success(t("generateSuccess"))
|
||||
notify.success(t("generateSuccess"))
|
||||
onCreated({
|
||||
id: result.data.id,
|
||||
code: result.data.code,
|
||||
@@ -256,10 +256,10 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
|
||||
})
|
||||
onClose()
|
||||
} else {
|
||||
toast.error(result.message ?? t("generateFailed"))
|
||||
notify.error(result.message ?? t("generateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("generateFailed"))
|
||||
notify.error(t("generateFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -33,14 +33,14 @@ export function GradeClassesClient({
|
||||
try {
|
||||
const res = await createGradeClassAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
data.setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
notify.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedCreate"))
|
||||
notify.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
@@ -52,14 +52,14 @@ export function GradeClassesClient({
|
||||
try {
|
||||
const res = await updateGradeClassAction(data.editItem.id, undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
data.setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
notify.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedUpdate"))
|
||||
notify.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
@@ -71,14 +71,14 @@ export function GradeClassesClient({
|
||||
try {
|
||||
const res = await deleteGradeClassAction(data.deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
data.setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedDelete"))
|
||||
notify.error(res.message || t("list.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedDelete"))
|
||||
notify.error(t("list.failedDelete"))
|
||||
} finally {
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -65,15 +65,15 @@ export function MyClassesGrid({
|
||||
try {
|
||||
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("invitation.joinSuccess"))
|
||||
notify.success(res.message || t("invitation.joinSuccess"))
|
||||
setJoinOpen(false)
|
||||
setJoinSubject("")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("invitation.joinFailed"))
|
||||
notify.error(res.message || t("invitation.joinFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("invitation.joinFailed"))
|
||||
notify.error(t("invitation.joinFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -220,13 +220,13 @@ function ClassTicket({
|
||||
try {
|
||||
const res = await ensureClassInvitationCodeAction(c.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("invitation.generateSuccess"))
|
||||
notify.success(res.message || t("invitation.generateSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("invitation.generateFailed"))
|
||||
notify.error(res.message || t("invitation.generateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("invitation.generateFailed"))
|
||||
notify.error(t("invitation.generateFailed"))
|
||||
} finally {
|
||||
onWorkingChange(false)
|
||||
}
|
||||
@@ -237,13 +237,13 @@ function ClassTicket({
|
||||
try {
|
||||
const res = await regenerateClassInvitationCodeAction(c.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("invitation.regenerateSuccess"))
|
||||
notify.success(res.message || t("invitation.regenerateSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedRegenerate"))
|
||||
notify.error(res.message || t("list.failedRegenerate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedRegenerate"))
|
||||
notify.error(t("list.failedRegenerate"))
|
||||
} finally {
|
||||
onWorkingChange(false)
|
||||
}
|
||||
@@ -254,9 +254,9 @@ function ClassTicket({
|
||||
if (!code) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
toast.success(t("myClasses.codeCopied"))
|
||||
notify.success(t("myClasses.codeCopied"))
|
||||
} catch {
|
||||
toast.error(t("list.failedCopy"))
|
||||
notify.error(t("list.failedCopy"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -58,14 +58,14 @@ export function ScheduleCreateDialog({
|
||||
formData.set("weekday", String(weekday))
|
||||
const res = await createClassScheduleItemAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
notify.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedCreate"))
|
||||
notify.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -40,14 +40,14 @@ export function ScheduleDeleteDialog({
|
||||
try {
|
||||
const res = await deleteClassScheduleItemAction(deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
onClose()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedDelete"))
|
||||
notify.error(res.message || t("list.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedDelete"))
|
||||
notify.error(t("list.failedDelete"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -59,14 +59,14 @@ export function ScheduleEditDialog({
|
||||
formData.set("weekday", editWeekday)
|
||||
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
onClose()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
notify.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedUpdate"))
|
||||
notify.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
@@ -33,13 +33,13 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
try {
|
||||
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedStatus"))
|
||||
notify.error(t("list.failedStatus"))
|
||||
} finally {
|
||||
setWorkingKey(null)
|
||||
}
|
||||
|
||||
@@ -18,32 +18,30 @@ import {
|
||||
} from "@/shared/db/schema"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
AdminClassListItem,
|
||||
ClassSubject,
|
||||
ClassSubjectTeacherAssignment,
|
||||
CreateTeacherClassInput,
|
||||
TeacherOption,
|
||||
UpdateTeacherClassInput,
|
||||
} from "./types"
|
||||
import {
|
||||
compareClassLike,
|
||||
generateUniqueInvitationCode,
|
||||
getClassSubjects,
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access"
|
||||
// G3-020 / G3-036 去重:isClassSubject / toClassSubject /
|
||||
// buildSubjectsByClassIdMap / composeAdminClassList 已提取至 lib/admin-class-mappers
|
||||
import {
|
||||
buildSubjectsByClassIdMap,
|
||||
composeAdminClassList,
|
||||
type AdminClassRow,
|
||||
type SubjectTeacherRow,
|
||||
} from "./lib/admin-class-mappers"
|
||||
|
||||
const log = createModuleLogger("classes")
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
const toClassSubject = (v: string): ClassSubject | null =>
|
||||
isClassSubject(v) ? v : null
|
||||
|
||||
export const getAdminClassesRaw = async (): Promise<AdminClassListItem[]> => {
|
||||
const [rows, subjectRows] = await Promise.all([
|
||||
(async () => {
|
||||
(async (): Promise<AdminClassRow[]> => {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
@@ -138,53 +136,11 @@ export const getAdminClassesRaw = async (): Promise<AdminClassListItem[]> => {
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)) as Promise<SubjectTeacherRow[]>,
|
||||
])
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.subject)
|
||||
if (!subject) continue
|
||||
const teacher =
|
||||
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||
: null
|
||||
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||
bySubject.set(subject, teacher)
|
||||
subjectsByClassId.set(r.classId, bySubject)
|
||||
}
|
||||
|
||||
const list = rows.map((r) => {
|
||||
const bySubject = subjectsByClassId.get(r.id)
|
||||
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||
subject,
|
||||
teacher: bySubject?.get(subject) ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
schoolName: r.schoolName,
|
||||
schoolId: r.schoolId,
|
||||
name: r.name,
|
||||
grade: r.grade,
|
||||
gradeId: r.gradeId,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
invitationCode: r.invitationCode ?? null,
|
||||
teacher: {
|
||||
id: r.teacherId,
|
||||
name: r.teacherName ?? "Unnamed",
|
||||
email: r.teacherEmail,
|
||||
},
|
||||
subjectTeachers,
|
||||
studentCount: Number(r.studentCount ?? 0),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
list.sort(compareClassLike)
|
||||
return list
|
||||
const subjectsByClassId = buildSubjectsByClassIdMap(subjectRows)
|
||||
return composeAdminClassList(rows, subjectsByClassId)
|
||||
}
|
||||
export const getAdminClasses = cacheFn(getAdminClassesRaw, {
|
||||
tags: ["classes", "classes:list"],
|
||||
@@ -203,7 +159,7 @@ export const getGradeManagedClassesRaw = async (userId: string): Promise<AdminCl
|
||||
const gradeIds = managedGradeIds.map((g) => g.id)
|
||||
|
||||
const [rows, subjectRows] = await Promise.all([
|
||||
(async () => {
|
||||
(async (): Promise<AdminClassRow[]> => {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
@@ -268,53 +224,11 @@ export const getGradeManagedClassesRaw = async (userId: string): Promise<AdminCl
|
||||
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||
.where(inArray(classes.gradeId, gradeIds))
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)) as Promise<SubjectTeacherRow[]>,
|
||||
])
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.subject)
|
||||
if (!subject) continue
|
||||
const teacher =
|
||||
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||
: null
|
||||
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||
bySubject.set(subject, teacher)
|
||||
subjectsByClassId.set(r.classId, bySubject)
|
||||
}
|
||||
|
||||
const list = rows.map((r) => {
|
||||
const bySubject = subjectsByClassId.get(r.id)
|
||||
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||
subject,
|
||||
teacher: bySubject?.get(subject) ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
schoolName: r.schoolName,
|
||||
schoolId: r.schoolId,
|
||||
name: r.name,
|
||||
grade: r.grade,
|
||||
gradeId: r.gradeId,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
invitationCode: r.invitationCode ?? null,
|
||||
teacher: {
|
||||
id: r.teacherId,
|
||||
name: r.teacherName ?? "Unnamed",
|
||||
email: r.teacherEmail,
|
||||
},
|
||||
subjectTeachers,
|
||||
studentCount: Number(r.studentCount ?? 0),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
list.sort(compareClassLike)
|
||||
return list
|
||||
const subjectsByClassId = buildSubjectsByClassIdMap(subjectRows)
|
||||
return composeAdminClassList(rows, subjectsByClassId)
|
||||
}
|
||||
export const getGradeManagedClasses = cacheFn(getGradeManagedClassesRaw, {
|
||||
tags: ["classes"],
|
||||
|
||||
@@ -282,9 +282,12 @@ export const getClassStudentsRaw = async (
|
||||
}
|
||||
|
||||
if (q && q.length > 0) {
|
||||
const needle = `%${q}%`
|
||||
// F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描;转义 LIKE 通配符
|
||||
// MySQL 默认 collation(utf8mb4_*_ci)不区分大小写,无需 LOWER()
|
||||
const escapeLike = (s: string) => s.replace(/[%_\\]/g, "\\$&")
|
||||
const needle = `${escapeLike(q)}%`
|
||||
conditions.push(
|
||||
sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})`
|
||||
sql`(${users.name} LIKE ${needle} OR ${users.email} LIKE ${needle})`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,10 @@ import {
|
||||
generateUniqueInvitationCode,
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access-invitations"
|
||||
// G3-036 去重:isClassSubject / toClassSubject 已提取至 lib/admin-class-mappers
|
||||
import { toClassSubject } from "./lib/admin-class-mappers"
|
||||
|
||||
const log = createModuleLogger("classes")
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
const toClassSubject = (v: string): ClassSubject | null =>
|
||||
isClassSubject(v) ? v : null
|
||||
|
||||
export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
@@ -90,13 +87,21 @@ export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Pro
|
||||
|
||||
list.sort(compareClassLike)
|
||||
|
||||
// Fetch recent assignments for trends and schedule
|
||||
// N+1 优化:单条 SQL 获取所有可访问班级的课表,按 classId 分组到 Map,
|
||||
// 替代原先每班级 1 条 SQL 的 N+1 模式。
|
||||
const allSchedule = await getClassSchedule({ teacherId })
|
||||
const scheduleByClassId = new Map<string, typeof allSchedule>()
|
||||
for (const item of allSchedule) {
|
||||
const arr = scheduleByClassId.get(item.classId) ?? []
|
||||
arr.push(item)
|
||||
scheduleByClassId.set(item.classId, arr)
|
||||
}
|
||||
|
||||
// Fetch recent assignments for trends(getClassHomeworkInsights 已通过 cacheFn 缓存)
|
||||
const listWithTrends = await Promise.all(
|
||||
list.map(async (c) => {
|
||||
const [insights, schedule] = await Promise.all([
|
||||
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
|
||||
getClassSchedule({ classId: c.id, teacherId }),
|
||||
])
|
||||
const insights = await getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 })
|
||||
const schedule = scheduleByClassId.get(c.id) ?? []
|
||||
|
||||
const recentAssignments = insights
|
||||
? insights.assignments.map((a) => ({
|
||||
@@ -125,6 +130,32 @@ export const getTeacherClasses = cacheFn(getTeacherClassesRaw, {
|
||||
keyParts: ["classes", "teacher", "list"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量获取多个班级的活跃学生 ID(Map<classId, studentId[]>)。
|
||||
*
|
||||
* 单条 `inArray` 查询替代 N 次 `getActiveStudentIdsByClassId` 调用,
|
||||
* 供跨模块调用使用以消除 N+1 查询。
|
||||
*/
|
||||
export const getActiveStudentIdsByClassIdsBatch = async (
|
||||
classIds: string[]
|
||||
): Promise<Map<string, string[]>> => {
|
||||
const result = new Map<string, string[]>()
|
||||
const uniqueIds = Array.from(new Set(classIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select({ classId: classEnrollments.classId, studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(inArray(classEnrollments.classId, uniqueIds), eq(classEnrollments.status, "active")))
|
||||
|
||||
for (const r of rows) {
|
||||
const list = result.get(r.classId) ?? []
|
||||
list.push(r.studentId)
|
||||
result.set(r.classId, list)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const getTeacherOptionsRaw = async (): Promise<TeacherOption[]> => {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
@@ -345,48 +376,52 @@ export async function enrollTeacherByInvitationCode(
|
||||
if (!cls) throw new Error("Invalid invitation code")
|
||||
if (cls.teacherId === tid) return cls.id
|
||||
|
||||
// F-09:将多步读写包装在单事务中保证原子性,避免中途失败留下脏数据。
|
||||
// 事务内统一使用 tx 执行查询,使读写看到一致的事务内状态。
|
||||
// 返回 [classId, shouldConsume]:早退(已分配)场景不消耗邀请码,保留原行为。
|
||||
const [enrolledClassId, shouldConsume] = await db.transaction<[string, boolean]>(async (tx) => {
|
||||
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
||||
const [existing] = await db
|
||||
const [existing] = await tx
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existing && !subjectValue) return cls.id
|
||||
if (existing && !subjectValue) return [cls.id, false]
|
||||
if (subjectValue) {
|
||||
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
|
||||
const [subRow] = await tx.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
|
||||
if (!subRow) throw new Error("Subject not found")
|
||||
const sid = subRow.id
|
||||
|
||||
const [mapping] = await db
|
||||
const [mapping] = await tx
|
||||
.select({ teacherId: classSubjectTeachers.teacherId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
|
||||
.limit(1)
|
||||
|
||||
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
|
||||
if (mapping?.teacherId === tid) return cls.id
|
||||
if (mapping?.teacherId === tid) return [cls.id, false]
|
||||
if (!mapping) {
|
||||
await db
|
||||
await tx
|
||||
.insert(classSubjectTeachers)
|
||||
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
||||
}
|
||||
|
||||
const [existingSubject] = await db
|
||||
const [existingSubject] = await tx
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSubject) return cls.id
|
||||
if (existingSubject) return [cls.id, false]
|
||||
|
||||
await db
|
||||
await tx
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const [assigned] = await db
|
||||
const [assigned] = await tx
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
@@ -394,7 +429,7 @@ export async function enrollTeacherByInvitationCode(
|
||||
|
||||
if (!assigned) throw new Error("Subject already assigned")
|
||||
} else {
|
||||
const subjectRows = await db
|
||||
const subjectRows = await tx
|
||||
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
@@ -406,7 +441,7 @@ export async function enrollTeacherByInvitationCode(
|
||||
if (!subjectRow) throw new Error("Subject not found")
|
||||
const sid = subjectRow.id
|
||||
|
||||
await db
|
||||
await tx
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(
|
||||
@@ -417,7 +452,7 @@ export async function enrollTeacherByInvitationCode(
|
||||
)
|
||||
)
|
||||
|
||||
const [assigned] = await db
|
||||
const [assigned] = await tx
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(
|
||||
@@ -432,12 +467,15 @@ export async function enrollTeacherByInvitationCode(
|
||||
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||
}
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
return [cls.id, true]
|
||||
})
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过) - 在事务外执行
|
||||
if (shouldConsume && result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return cls.id
|
||||
return enrolledClassId
|
||||
}
|
||||
|
||||
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import "server-only";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
|
||||
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
|
||||
const DEFAULT_LIMIT = 1000
|
||||
|
||||
export const getSessionTeacherId = async (): Promise<string | null> => {
|
||||
const { auth } = await import("@/auth")
|
||||
const session = await auth()
|
||||
@@ -36,7 +40,7 @@ export const getTeacherIdForMutations = async (): Promise<string> => {
|
||||
return teacherId
|
||||
}
|
||||
|
||||
export const getClassSubjects = async (): Promise<string[]> => {
|
||||
export const getClassSubjectsRaw = async (): Promise<string[]> => {
|
||||
const rows = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
@@ -45,6 +49,11 @@ export const getClassSubjects = async (): Promise<string[]> => {
|
||||
// P1-5: subjects 表为空时回退到默认科目列表,避免班级创建流程中断
|
||||
return Array.from(new Set(names.length > 0 ? names : DEFAULT_CLASS_SUBJECTS))
|
||||
}
|
||||
export const getClassSubjects = cacheFn(getClassSubjectsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassSubjects"],
|
||||
})
|
||||
|
||||
const normalizeSortText = (v: string | null | undefined): string =>
|
||||
typeof v === "string" ? v.trim().toLowerCase() : ""
|
||||
@@ -80,7 +89,7 @@ export const compareClassLike = (
|
||||
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
|
||||
}
|
||||
|
||||
export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
|
||||
export const getAccessibleClassIdsForTeacherRaw = async (teacherId: string): Promise<string[]> => {
|
||||
const [ownedIds, assignedIds] = await Promise.all([
|
||||
db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)),
|
||||
db
|
||||
@@ -90,6 +99,11 @@ export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promis
|
||||
])
|
||||
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
|
||||
}
|
||||
export const getAccessibleClassIdsForTeacher = cacheFn(getAccessibleClassIdsForTeacherRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getAccessibleClassIdsForTeacher"],
|
||||
})
|
||||
|
||||
/**
|
||||
* Verify that a teacher owns a class (teacherId match on classes row).
|
||||
@@ -104,7 +118,7 @@ export async function verifyTeacherOwnsClass(classId: string, teacherId: string)
|
||||
return Boolean(owned)
|
||||
}
|
||||
|
||||
export const getClassGradeIdsByClassIds = async (classIds: string[]): Promise<Map<string, string>> => {
|
||||
export const getClassGradeIdsByClassIdsRaw = async (classIds: string[]): Promise<Map<string, string>> => {
|
||||
if (classIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({ id: classes.id, gradeId: classes.gradeId })
|
||||
@@ -118,20 +132,30 @@ export const getClassGradeIdsByClassIds = async (classIds: string[]): Promise<Ma
|
||||
}
|
||||
return map
|
||||
}
|
||||
export const getClassGradeIdsByClassIds = cacheFn(getClassGradeIdsByClassIdsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassGradeIdsByClassIds"],
|
||||
})
|
||||
|
||||
export const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
|
||||
export const getTeacherSubjectIdsForClassRaw = async (teacherId: string, classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
|
||||
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
|
||||
}
|
||||
export const getTeacherSubjectIdsForClass = cacheFn(getTeacherSubjectIdsForClassRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getTeacherSubjectIdsForClass"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级的教师 ID(班主任)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassTeacherById = async (classId: string): Promise<string | null> => {
|
||||
export const getClassTeacherByIdRaw = async (classId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
@@ -139,24 +163,34 @@ export const getClassTeacherById = async (classId: string): Promise<string | nul
|
||||
.limit(1)
|
||||
return row?.teacherId ?? null
|
||||
}
|
||||
export const getClassTeacherById = cacheFn(getClassTeacherByIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassTeacherById"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级所有学生 ID(不限状态)。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getStudentIdsByClassId = async (classId: string): Promise<string[]> => {
|
||||
export const getStudentIdsByClassIdRaw = async (classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, classId))
|
||||
return rows.map((r) => r.studentId)
|
||||
}
|
||||
export const getStudentIdsByClassId = cacheFn(getStudentIdsByClassIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getStudentIdsByClassId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取多个班级的所有学生 ID(不限状态)。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getStudentIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
|
||||
export const getStudentIdsByClassIdsRaw = async (classIds: string[]): Promise<string[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
@@ -164,24 +198,34 @@ export const getStudentIdsByClassIds = async (classIds: string[]): Promise<strin
|
||||
.where(inArray(classEnrollments.classId, classIds))
|
||||
return Array.from(new Set(rows.map((r) => r.studentId)))
|
||||
}
|
||||
export const getStudentIdsByClassIds = cacheFn(getStudentIdsByClassIdsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getStudentIdsByClassIds"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级所有活跃学生 ID(status = 'active')。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getActiveStudentIdsByClassId = async (classId: string): Promise<string[]> => {
|
||||
export const getActiveStudentIdsByClassIdRaw = async (classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
return rows.map((r) => r.studentId)
|
||||
}
|
||||
export const getActiveStudentIdsByClassId = cacheFn(getActiveStudentIdsByClassIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getActiveStudentIdsByClassId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级所有活跃学生基本信息(id/name/email),按姓名升序。
|
||||
* 供跨模块调用使用(如考勤点名),避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getClassActiveStudentsWithInfo = async (
|
||||
export const getClassActiveStudentsWithInfoRaw = async (
|
||||
classId: string
|
||||
): Promise<Array<{ id: string; name: string; email: string }>> => {
|
||||
const rows = await db
|
||||
@@ -192,20 +236,30 @@ export const getClassActiveStudentsWithInfo = async (
|
||||
.orderBy(asc(users.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
|
||||
}
|
||||
export const getClassActiveStudentsWithInfo = cacheFn(getClassActiveStudentsWithInfoRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getClassActiveStudentsWithInfo"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取教师在一个班级所教的科目 ID 列表。
|
||||
* 参数顺序为 (classId, teacherId),供跨模块调用使用。
|
||||
*/
|
||||
export const getTeacherSubjectIdsByClass = async (classId: string, teacherId: string): Promise<string[]> => {
|
||||
export const getTeacherSubjectIdsByClassRaw = async (classId: string, teacherId: string): Promise<string[]> => {
|
||||
return getTeacherSubjectIdsForClass(teacherId, classId)
|
||||
}
|
||||
export const getTeacherSubjectIdsByClass = cacheFn(getTeacherSubjectIdsByClassRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getTeacherSubjectIdsByClass"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取多个班级的所有教师 ID(班主任 + 任课教师)。
|
||||
* 供跨模块调用使用,避免直接查询 classes / classSubjectTeachers 表。
|
||||
*/
|
||||
export const getTeacherIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
|
||||
export const getTeacherIdsByClassIdsRaw = async (classIds: string[]): Promise<string[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
const [homeroomRows, subjectRows] = await Promise.all([
|
||||
db
|
||||
@@ -226,12 +280,17 @@ export const getTeacherIdsByClassIds = async (classIds: string[]): Promise<strin
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
export const getTeacherIdsByClassIds = cacheFn(getTeacherIdsByClassIdsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getTeacherIdsByClassIds"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级的 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getStudentActiveClassId = async (studentId: string): Promise<string | null> => {
|
||||
export const getStudentActiveClassIdRaw = async (studentId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
@@ -240,12 +299,17 @@ export const getStudentActiveClassId = async (studentId: string): Promise<string
|
||||
.limit(1)
|
||||
return row?.classId ?? null
|
||||
}
|
||||
export const getStudentActiveClassId = cacheFn(getStudentActiveClassIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getStudentActiveClassId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级的 ID 与名称(一次 JOIN 查询)。
|
||||
* 供跨模块调用使用,避免分别查询 classEnrollments 与 classes 表。
|
||||
*/
|
||||
export const getStudentActiveClass = async (
|
||||
export const getStudentActiveClassRaw = async (
|
||||
studentId: string,
|
||||
): Promise<{ classId: string; className: string } | null> => {
|
||||
const [row] = await db
|
||||
@@ -257,12 +321,17 @@ export const getStudentActiveClass = async (
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
export const getStudentActiveClass = cacheFn(getStudentActiveClassRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getStudentActiveClass"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级对应的年级 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments/classes 表。
|
||||
*/
|
||||
export const getStudentActiveGradeId = async (studentId: string): Promise<string | null> => {
|
||||
export const getStudentActiveGradeIdRaw = async (studentId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
@@ -272,12 +341,17 @@ export const getStudentActiveGradeId = async (studentId: string): Promise<string
|
||||
.limit(1)
|
||||
return row?.gradeId ?? null
|
||||
}
|
||||
export const getStudentActiveGradeId = cacheFn(getStudentActiveGradeIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getStudentActiveGradeId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 校验班级是否存在。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassExists = async (classId: string): Promise<boolean> => {
|
||||
export const getClassExistsRaw = async (classId: string): Promise<boolean> => {
|
||||
const [row] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
@@ -285,12 +359,17 @@ export const getClassExists = async (classId: string): Promise<boolean> => {
|
||||
.limit(1)
|
||||
return Boolean(row)
|
||||
}
|
||||
export const getClassExists = cacheFn(getClassExistsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 60,
|
||||
keyParts: ["classes", "getClassExists"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级名称。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassNameById = async (classId: string): Promise<string | null> => {
|
||||
export const getClassNameByIdRaw = async (classId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ name: classes.name })
|
||||
.from(classes)
|
||||
@@ -298,12 +377,17 @@ export const getClassNameById = async (classId: string): Promise<string | null>
|
||||
.limit(1)
|
||||
return row?.name ?? null
|
||||
}
|
||||
export const getClassNameById = cacheFn(getClassNameByIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassNameById"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取班级关联的年级 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassGradeId = async (classId: string): Promise<string | null> => {
|
||||
export const getClassGradeIdRaw = async (classId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
@@ -311,12 +395,17 @@ export const getClassGradeId = async (classId: string): Promise<string | null> =
|
||||
.limit(1)
|
||||
return row?.gradeId ?? null
|
||||
}
|
||||
export const getClassGradeId = cacheFn(getClassGradeIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassGradeId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取多个班级关联的年级 ID 列表(去重,过滤空值)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getGradeIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
|
||||
export const getGradeIdsByClassIdsRaw = async (classIds: string[]): Promise<string[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
const rows = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
@@ -326,12 +415,17 @@ export const getGradeIdsByClassIds = async (classIds: string[]): Promise<string[
|
||||
.map((r) => r.gradeId)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
}
|
||||
export const getGradeIdsByClassIds = cacheFn(getGradeIdsByClassIdsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getGradeIdsByClassIds"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量获取班级名称(Map<classId, name>)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassNamesByIds = async (classIds: string[]): Promise<Map<string, string>> => {
|
||||
export const getClassNamesByIdsRaw = async (classIds: string[]): Promise<Map<string, string>> => {
|
||||
const result = new Map<string, string>()
|
||||
const uniqueIds = Array.from(new Set(classIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return result
|
||||
@@ -344,25 +438,36 @@ export const getClassNamesByIds = async (classIds: string[]): Promise<Map<string
|
||||
for (const r of rows) result.set(r.id, r.name)
|
||||
return result
|
||||
}
|
||||
export const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassNamesByIds"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取指定年级下的所有班级(id + name)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id: string; name: string }>> => {
|
||||
export const getClassesByGradeIdRaw = async (gradeId: string): Promise<Array<{ id: string; name: string }>> => {
|
||||
if (!gradeId) return []
|
||||
const rows = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.gradeId, gradeId))
|
||||
.limit(DEFAULT_LIMIT)
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
}
|
||||
export const getClassesByGradeId = cacheFn(getClassesByGradeIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassesByGradeId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取多个年级下的所有班级 ID(供 grades 模块 grade_managed scope 过滤使用)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
|
||||
export const getClassIdsByGradeIdsRaw = async (gradeIds: string[]): Promise<string[]> => {
|
||||
const uniqueIds = Array.from(new Set(gradeIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return []
|
||||
const rows = await db
|
||||
@@ -371,6 +476,11 @@ export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise<string[
|
||||
.where(inArray(classes.gradeId, uniqueIds))
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
export const getClassIdsByGradeIds = cacheFn(getClassIdsByGradeIdsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassIdsByGradeIds"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 构建一个 Drizzle 子查询 SQL,用于过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))。
|
||||
@@ -380,11 +490,137 @@ export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => {
|
||||
return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module interfaces — scheduling selectors (avoid direct schema queries)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 班级轻量信息(供排课模块选择器使用) */
|
||||
export type ClassSchedulingOption = {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取所有班级的轻量信息(id/name/grade),供排课模块选择器使用。
|
||||
*
|
||||
* 避免 scheduling 模块直接查询 classes 表。
|
||||
*/
|
||||
export const getClassesForSchedulingRaw = async (): Promise<ClassSchedulingOption[]> => {
|
||||
const rows = await db
|
||||
.select({ id: classes.id, name: classes.name, grade: classes.grade })
|
||||
.from(classes)
|
||||
.orderBy(asc(classes.grade), asc(classes.name))
|
||||
.limit(DEFAULT_LIMIT)
|
||||
return rows.map((r) => ({ id: r.id, name: r.name, grade: r.grade }))
|
||||
}
|
||||
export const getClassesForScheduling = cacheFn(getClassesForSchedulingRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassesForScheduling"],
|
||||
})
|
||||
|
||||
/** 班级科目分配(仅 subjectId + teacherId,科目名称需调用方通过 school 模块解析) */
|
||||
export type ClassSubjectAssignment = {
|
||||
subjectId: string
|
||||
teacherId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取指定班级的科目-教师分配(仅 subjectId + teacherId)。
|
||||
*
|
||||
* 供 scheduling 模块获取班级科目任课教师,避免直查 classSubjectTeachers 表。
|
||||
* 科目名称需调用方通过 school 模块 getSubjectNameMapByIds 解析。
|
||||
*/
|
||||
export const getClassSubjectAssignmentsByClassIdRaw = async (
|
||||
classId: string
|
||||
): Promise<ClassSubjectAssignment[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: classSubjectTeachers.subjectId,
|
||||
teacherId: classSubjectTeachers.teacherId,
|
||||
})
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.classId, classId))
|
||||
return rows.map((r) => ({ subjectId: r.subjectId, teacherId: r.teacherId ?? null }))
|
||||
}
|
||||
export const getClassSubjectAssignmentsByClassId = cacheFn(getClassSubjectAssignmentsByClassIdRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getClassSubjectAssignmentsByClassId"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取所有班级-科目-教师分配中的去重教师 ID 列表。
|
||||
*
|
||||
* 供 scheduling 模块获取"有任课的教师"列表,避免直查 classSubjectTeachers 表。
|
||||
* 教师详情需调用方通过 users 模块 getTeachersByIds 解析。
|
||||
*/
|
||||
export const getDistinctTeacherIdsFromAssignmentsRaw = async (): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.selectDistinct({ teacherId: classSubjectTeachers.teacherId })
|
||||
.from(classSubjectTeachers)
|
||||
.limit(DEFAULT_LIMIT)
|
||||
return rows
|
||||
.map((r) => r.teacherId)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
}
|
||||
export const getDistinctTeacherIdsFromAssignments = cacheFn(getDistinctTeacherIdsFromAssignmentsRaw, {
|
||||
tags: ["classes"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "getDistinctTeacherIdsFromAssignments"],
|
||||
})
|
||||
|
||||
// Re-export from split files for backward compatibility
|
||||
export * from "./data-access-stats"
|
||||
export * from "./data-access-schedule"
|
||||
export * from "./data-access-students"
|
||||
export * from "./data-access-admin"
|
||||
// G3-048: 对导出数 < 15 的子文件改为显式 re-export,便于 IDE 跳转与命名冲突检测;
|
||||
// 导出数 >= 15 的子文件保留 export * 以避免遗漏符号(见下方注释)。
|
||||
export {
|
||||
getClassHomeworkInsightsRaw,
|
||||
getClassHomeworkInsights,
|
||||
getGradeHomeworkInsightsRaw,
|
||||
getGradeHomeworkInsights,
|
||||
getClassesDashboardStatsRaw,
|
||||
getClassesDashboardStats,
|
||||
} from "./data-access-stats"
|
||||
export type { ClassesDashboardStats } from "./data-access-stats"
|
||||
|
||||
export {
|
||||
getClassIdByScheduleId,
|
||||
getStudentScheduleRaw,
|
||||
getStudentSchedule,
|
||||
getClassScheduleRaw,
|
||||
getClassSchedule,
|
||||
} from "./data-access-schedule"
|
||||
|
||||
export {
|
||||
getStudentsSubjectScoresRaw,
|
||||
getStudentsSubjectScores,
|
||||
getClassStudentSubjectScoresV2Raw,
|
||||
getClassStudentSubjectScoresV2,
|
||||
getStudentClassesRaw,
|
||||
getStudentClasses,
|
||||
getStudentClassByIdRaw,
|
||||
getStudentClassById,
|
||||
getClassStudentsRaw,
|
||||
getClassStudents,
|
||||
getStudentScopeData,
|
||||
getGradeIdsForStudentIds,
|
||||
} from "./data-access-students"
|
||||
|
||||
export {
|
||||
getAdminClassesRaw,
|
||||
getAdminClasses,
|
||||
getGradeManagedClassesRaw,
|
||||
getGradeManagedClasses,
|
||||
getManagedGradesRaw,
|
||||
getManagedGrades,
|
||||
createAdminClass,
|
||||
updateAdminClass,
|
||||
deleteAdminClass,
|
||||
} from "./data-access-admin"
|
||||
|
||||
// 以下子文件导出数 >= 15(invitations 22 个、teacher 18 个),显式列举易遗漏,
|
||||
// 保留 export * 以保证完整性。详见 G3-048 审计建议。
|
||||
export * from "./data-access-invitations"
|
||||
export * from "./data-access-teacher"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { ArrowLeft, Pencil, Plus, Trash2, Download } from "lucide-react"
|
||||
import {
|
||||
DndContext,
|
||||
@@ -88,15 +88,15 @@ export function CoursePlanDetail({
|
||||
try {
|
||||
const res = await deleteCoursePlanAction(plan.id)
|
||||
if (res.success) {
|
||||
toast.success(t("toast.deleted"))
|
||||
notify.success(t("toast.deleted"))
|
||||
trackCoursePlanEvent("plan_deleted", { planId: plan.id })
|
||||
router.push(successHref ?? "/admin/course-plans")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("toast.deleteFailed"))
|
||||
notify.error(res.message || t("toast.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("toast.deleteFailed"))
|
||||
notify.error(t("toast.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
setDeleteOpen(false)
|
||||
@@ -128,14 +128,14 @@ export function CoursePlanDetail({
|
||||
try {
|
||||
const res = await bulkToggleItemsAction(Array.from(selectedIds), true)
|
||||
if (res.success) {
|
||||
toast.success(t("toast.bulkMarked", { count: res.data ?? 0 }))
|
||||
notify.success(t("toast.bulkMarked", { count: res.data ?? 0 }))
|
||||
setSelectedIds(new Set())
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(t("toast.bulkFailed"))
|
||||
notify.error(t("toast.bulkFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("toast.bulkFailed"))
|
||||
notify.error(t("toast.bulkFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -166,14 +166,14 @@ export function CoursePlanDetail({
|
||||
try {
|
||||
const res = await reorderCoursePlanItemsAction({ planId: plan.id, items })
|
||||
if (res.success) {
|
||||
toast.success(t("detail.reorderSaved"))
|
||||
notify.success(t("detail.reorderSaved"))
|
||||
trackCoursePlanEvent("items_reordered", { planId: plan.id, count: items.length })
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(t("detail.reorderFailed"))
|
||||
notify.error(t("detail.reorderFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("detail.reorderFailed"))
|
||||
notify.error(t("detail.reorderFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -200,10 +200,10 @@ export function CoursePlanDetail({
|
||||
},
|
||||
filename,
|
||||
)
|
||||
toast.success(t("export.exported"))
|
||||
notify.success(t("export.exported"))
|
||||
trackCoursePlanEvent("plan_exported", { planId: plan.id, format: "csv" })
|
||||
} catch {
|
||||
toast.error(t("export.exportFailed"))
|
||||
notify.error(t("export.exportFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -92,19 +92,19 @@ export function CoursePlanForm({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error(t("form.invalidState"))
|
||||
notify.error(t("form.invalidState"))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.push(successHref ?? "/admin/course-plans")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("form.saveFailed"))
|
||||
notify.error(res.message || t("form.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("form.saveFailed"))
|
||||
notify.error(t("form.saveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { JSX } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { Check, Trash2, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -59,19 +59,19 @@ export function CoursePlanItemEditor({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error(t("item.invalidState"))
|
||||
notify.error(t("item.invalidState"))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("item.saveFailed"))
|
||||
notify.error(res.message || t("item.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("item.saveFailed"))
|
||||
notify.error(t("item.saveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -83,14 +83,14 @@ export function CoursePlanItemEditor({
|
||||
try {
|
||||
const res = await deleteCoursePlanItemAction(item.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("item.deleteFailed"))
|
||||
notify.error(res.message || t("item.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("item.deleteFailed"))
|
||||
notify.error(t("item.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -102,13 +102,13 @@ export function CoursePlanItemEditor({
|
||||
try {
|
||||
const res = await toggleCoursePlanItemCompletedAction(item.id, !item.isCompleted)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("item.updateFailed"))
|
||||
notify.error(res.message || t("item.updateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("item.updateFailed"))
|
||||
notify.error(t("item.updateFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { JSX } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { FileText, Loader2, Search } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -110,7 +110,7 @@ export function TemplatePickerDialog({
|
||||
try {
|
||||
const res = await copyCoursePlanAction(selectedId, [targetClassId])
|
||||
if (res.success && res.data && res.data.length > 0) {
|
||||
toast.success(t("templates.cloneSuccess"))
|
||||
notify.success(t("templates.cloneSuccess"))
|
||||
trackCoursePlanEvent("plan_created_from_template", {
|
||||
sourcePlanId: selectedId,
|
||||
newPlanId: res.data[0],
|
||||
@@ -119,10 +119,10 @@ export function TemplatePickerDialog({
|
||||
router.push(`${successHref}/${res.data[0]}/edit`)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("templates.cloneFailed"))
|
||||
notify.error(res.message || t("templates.cloneFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("templates.cloneFailed"))
|
||||
notify.error(t("templates.cloneFailed"))
|
||||
} finally {
|
||||
setCloning(false)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||
import { db } from "@/shared/db"
|
||||
import { coursePlanItems, coursePlans } from "@/shared/db/schema"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import type {
|
||||
CoursePlan,
|
||||
@@ -27,10 +28,6 @@ import type {
|
||||
} from "./schema"
|
||||
|
||||
const log = createModuleLogger("course-plans")
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
type PlanRow = typeof coursePlans.$inferSelect
|
||||
|
||||
@@ -158,7 +155,25 @@ export const getCoursePlansRaw = async (params?: GetCoursePlansParams, scope?: C
|
||||
if (params?.status)
|
||||
conditions.push(eq(coursePlans.status, params.status))
|
||||
|
||||
const query = db.select().from(coursePlans)
|
||||
const query = db.select({
|
||||
id: coursePlans.id,
|
||||
classId: coursePlans.classId,
|
||||
subjectId: coursePlans.subjectId,
|
||||
teacherId: coursePlans.teacherId,
|
||||
academicYearId: coursePlans.academicYearId,
|
||||
semester: coursePlans.semester,
|
||||
totalHours: coursePlans.totalHours,
|
||||
completedHours: coursePlans.completedHours,
|
||||
weeklyHours: coursePlans.weeklyHours,
|
||||
startDate: coursePlans.startDate,
|
||||
endDate: coursePlans.endDate,
|
||||
syllabus: coursePlans.syllabus,
|
||||
objectives: coursePlans.objectives,
|
||||
status: coursePlans.status,
|
||||
createdBy: coursePlans.createdBy,
|
||||
createdAt: coursePlans.createdAt,
|
||||
updatedAt: coursePlans.updatedAt,
|
||||
}).from(coursePlans)
|
||||
const rows = await (conditions.length > 0
|
||||
? query.where(and(...conditions))
|
||||
: query
|
||||
@@ -181,7 +196,25 @@ export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQuerySc
|
||||
try {
|
||||
const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)]
|
||||
const [planRow] = await db
|
||||
.select()
|
||||
.select({
|
||||
id: coursePlans.id,
|
||||
classId: coursePlans.classId,
|
||||
subjectId: coursePlans.subjectId,
|
||||
teacherId: coursePlans.teacherId,
|
||||
academicYearId: coursePlans.academicYearId,
|
||||
semester: coursePlans.semester,
|
||||
totalHours: coursePlans.totalHours,
|
||||
completedHours: coursePlans.completedHours,
|
||||
weeklyHours: coursePlans.weeklyHours,
|
||||
startDate: coursePlans.startDate,
|
||||
endDate: coursePlans.endDate,
|
||||
syllabus: coursePlans.syllabus,
|
||||
objectives: coursePlans.objectives,
|
||||
status: coursePlans.status,
|
||||
createdBy: coursePlans.createdBy,
|
||||
createdAt: coursePlans.createdAt,
|
||||
updatedAt: coursePlans.updatedAt,
|
||||
})
|
||||
.from(coursePlans)
|
||||
.where(and(...conditions))
|
||||
.limit(1)
|
||||
@@ -191,7 +224,20 @@ export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQuerySc
|
||||
const enriched = await enrichPlanRow(planRow)
|
||||
|
||||
const itemRows = await db
|
||||
.select()
|
||||
.select({
|
||||
id: coursePlanItems.id,
|
||||
planId: coursePlanItems.planId,
|
||||
week: coursePlanItems.week,
|
||||
topic: coursePlanItems.topic,
|
||||
content: coursePlanItems.content,
|
||||
hours: coursePlanItems.hours,
|
||||
textbookChapter: coursePlanItems.textbookChapter,
|
||||
notes: coursePlanItems.notes,
|
||||
isCompleted: coursePlanItems.isCompleted,
|
||||
completedAt: coursePlanItems.completedAt,
|
||||
createdAt: coursePlanItems.createdAt,
|
||||
updatedAt: coursePlanItems.updatedAt,
|
||||
})
|
||||
.from(coursePlanItems)
|
||||
.where(eq(coursePlanItems.planId, id))
|
||||
.orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt))
|
||||
@@ -323,14 +369,17 @@ export async function reorderCoursePlanItems(
|
||||
|
||||
if (!existing) return
|
||||
|
||||
// 单事务保证原子性(F-09),并行 UPDATE 提升吞吐。
|
||||
await db.transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
items.map((item) =>
|
||||
db
|
||||
tx
|
||||
.update(coursePlanItems)
|
||||
.set({ week: item.week })
|
||||
.where(eq(coursePlanItems.id, item.id))
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,7 +414,25 @@ export async function copyCoursePlanToClasses(
|
||||
if (targetClassIds.length === 0) return []
|
||||
|
||||
const [source] = await db
|
||||
.select()
|
||||
.select({
|
||||
id: coursePlans.id,
|
||||
classId: coursePlans.classId,
|
||||
subjectId: coursePlans.subjectId,
|
||||
teacherId: coursePlans.teacherId,
|
||||
academicYearId: coursePlans.academicYearId,
|
||||
semester: coursePlans.semester,
|
||||
totalHours: coursePlans.totalHours,
|
||||
completedHours: coursePlans.completedHours,
|
||||
weeklyHours: coursePlans.weeklyHours,
|
||||
startDate: coursePlans.startDate,
|
||||
endDate: coursePlans.endDate,
|
||||
syllabus: coursePlans.syllabus,
|
||||
objectives: coursePlans.objectives,
|
||||
status: coursePlans.status,
|
||||
createdBy: coursePlans.createdBy,
|
||||
createdAt: coursePlans.createdAt,
|
||||
updatedAt: coursePlans.updatedAt,
|
||||
})
|
||||
.from(coursePlans)
|
||||
.where(eq(coursePlans.id, sourcePlanId))
|
||||
.limit(1)
|
||||
@@ -373,7 +440,20 @@ export async function copyCoursePlanToClasses(
|
||||
if (!source) return []
|
||||
|
||||
const sourceItems = await db
|
||||
.select()
|
||||
.select({
|
||||
id: coursePlanItems.id,
|
||||
planId: coursePlanItems.planId,
|
||||
week: coursePlanItems.week,
|
||||
topic: coursePlanItems.topic,
|
||||
content: coursePlanItems.content,
|
||||
hours: coursePlanItems.hours,
|
||||
textbookChapter: coursePlanItems.textbookChapter,
|
||||
notes: coursePlanItems.notes,
|
||||
isCompleted: coursePlanItems.isCompleted,
|
||||
completedAt: coursePlanItems.completedAt,
|
||||
createdAt: coursePlanItems.createdAt,
|
||||
updatedAt: coursePlanItems.updatedAt,
|
||||
})
|
||||
.from(coursePlanItems)
|
||||
.where(eq(coursePlanItems.planId, sourcePlanId))
|
||||
|
||||
@@ -450,7 +530,25 @@ export const getGradeCoursePlanProgressRaw = async (params: { gradeId: string })
|
||||
|
||||
// 查询年级下所有教学计划(仅 course_plans 表)
|
||||
const planRows = await db
|
||||
.select()
|
||||
.select({
|
||||
id: coursePlans.id,
|
||||
classId: coursePlans.classId,
|
||||
subjectId: coursePlans.subjectId,
|
||||
teacherId: coursePlans.teacherId,
|
||||
academicYearId: coursePlans.academicYearId,
|
||||
semester: coursePlans.semester,
|
||||
totalHours: coursePlans.totalHours,
|
||||
completedHours: coursePlans.completedHours,
|
||||
weeklyHours: coursePlans.weeklyHours,
|
||||
startDate: coursePlans.startDate,
|
||||
endDate: coursePlans.endDate,
|
||||
syllabus: coursePlans.syllabus,
|
||||
objectives: coursePlans.objectives,
|
||||
status: coursePlans.status,
|
||||
createdBy: coursePlans.createdBy,
|
||||
createdAt: coursePlans.createdAt,
|
||||
updatedAt: coursePlans.updatedAt,
|
||||
})
|
||||
.from(coursePlans)
|
||||
.where(inArray(coursePlans.classId, classIds))
|
||||
.orderBy(desc(coursePlans.createdAt))
|
||||
|
||||
@@ -11,6 +11,11 @@ import { useTranslations } from "next-intl"
|
||||
/** 时间范围选项 */
|
||||
export type TimeRange = "today" | "week" | "month"
|
||||
|
||||
/** 类型守卫:判断字符串是否为合法的 TimeRange */
|
||||
function isTimeRange(v: string): v is TimeRange {
|
||||
return v === "today" || v === "week" || v === "month"
|
||||
}
|
||||
|
||||
const RANGE_OPTIONS: { value: TimeRange; icon: typeof CalendarDays }[] = [
|
||||
{ value: "today", icon: CalendarDays },
|
||||
{ value: "week", icon: CalendarRange },
|
||||
@@ -30,7 +35,8 @@ export function DashboardTimeRangeFilter({ className }: { className?: string })
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const currentRange = (searchParams.get("range") as TimeRange | null) ?? "today"
|
||||
const rawRange = searchParams.get("range")
|
||||
const currentRange: TimeRange = rawRange && isTimeRange(rawRange) ? rawRange : "today"
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(range: TimeRange) => {
|
||||
|
||||
@@ -17,7 +17,15 @@ function loadPreferences(role: DashboardRole): WidgetPreferences {
|
||||
if (typeof window === "undefined") return {}
|
||||
try {
|
||||
const stored = localStorage.getItem(`${STORAGE_KEY}-${role}`)
|
||||
return stored ? (JSON.parse(stored) as WidgetPreferences) : {}
|
||||
if (!stored) return {}
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
// 从 unknown 转换:localStorage 数据不可信,做字段类型校验
|
||||
if (typeof parsed !== "object" || parsed === null) return {}
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === "boolean") result[k] = v
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ export function useDashboardRealtime<T = unknown>(
|
||||
|
||||
eventSource.addEventListener(eventName, (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as T
|
||||
const raw: unknown = JSON.parse(event.data)
|
||||
// 从 unknown 转换:SSE 数据结构由后端保证,泛型 T 的运行时校验由调用方负责
|
||||
const data = raw as T
|
||||
setLastMessage(data)
|
||||
setLastUpdatedAt(new Date())
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Users, AlertTriangle, TrendingUp, FileText, Filter } from "lucide-react"
|
||||
|
||||
@@ -77,10 +77,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
const result = await service.generateClassReport(summary.classId, period)
|
||||
setIsGenerating(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
notify.success(result.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("error.generateClassFailed"))
|
||||
notify.error(result.message || t("error.generateClassFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
if (result.success && result.data) {
|
||||
setFilteredStudents(result.data)
|
||||
} else {
|
||||
toast.error(result.message || t("error.loadFailed"))
|
||||
notify.error(result.message || t("error.loadFailed"))
|
||||
setFilteredStudents(null)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.loadFailed"))
|
||||
notify.error(t("error.loadFailed"))
|
||||
setFilteredStudents(null)
|
||||
} finally {
|
||||
setIsFiltering(false)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { FileText, Trash2, Send, Download } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -88,11 +88,11 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const result = await service.publishReport(publishId)
|
||||
setIsBusy(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
notify.success(result.message)
|
||||
setPublishId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("error.publishFailed"))
|
||||
notify.error(result.message || t("error.publishFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +102,11 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const result = await service.deleteReport(deleteId)
|
||||
setIsBusy(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
notify.success(result.message)
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("error.deleteFailed"))
|
||||
notify.error(result.message || t("error.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
try {
|
||||
const result = await service.exportReport(reportId)
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.message || t("error.exportFailed"))
|
||||
notify.error(result.message || t("error.exportFailed"))
|
||||
return
|
||||
}
|
||||
// base64 -> Blob -> 下载
|
||||
@@ -139,9 +139,9 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t("reportList.exportSuccess"))
|
||||
notify.success(t("reportList.exportSuccess"))
|
||||
} catch {
|
||||
toast.error(t("error.exportFailed"))
|
||||
notify.error(t("error.exportFailed"))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
|
||||
@@ -107,7 +107,11 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
|
||||
|
||||
// 读取已有掌握度记录,累积计算(而非覆盖)
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.select({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
totalQuestions: knowledgePointMastery.totalQuestions,
|
||||
correctQuestions: knowledgePointMastery.correctQuestions,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.where(
|
||||
and(
|
||||
@@ -192,7 +196,11 @@ export async function updateMasteryFromHomeworkSubmission(submissionId: string):
|
||||
|
||||
// 读取已有掌握度记录,累积计算(而非覆盖)
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.select({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
totalQuestions: knowledgePointMastery.totalQuestions,
|
||||
correctQuestions: knowledgePointMastery.correctQuestions,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.where(
|
||||
and(
|
||||
@@ -282,7 +290,11 @@ export async function updateMasteryFromExamScore(
|
||||
|
||||
// 读取已有掌握度记录,累积计算
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.select({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
totalQuestions: knowledgePointMastery.totalQuestions,
|
||||
correctQuestions: knowledgePointMastery.correctQuestions,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.where(
|
||||
and(
|
||||
@@ -551,3 +563,115 @@ export const getClassStudentsByKnowledgePoint = cacheFn(getClassStudentsByKnowle
|
||||
ttl: 300,
|
||||
keyParts: ["diagnostic", "getClassStudentsByKnowledgePoint"],
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module batch interfaces — mastery by knowledge point id set
|
||||
// ---------------------------------------------------------------------------
|
||||
// 供 textbooks 模块知识图谱使用,避免直查 knowledgePointMastery 表。
|
||||
// 仅查询 knowledgePointMastery 表(diagnostic 模块自有),由调用方传入 kpIds。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 知识点掌握度快照(与 textbooks.MasteryInfo 结构兼容) */
|
||||
export type MasterySnapshot = {
|
||||
masteryLevel: number
|
||||
totalQuestions: number
|
||||
correctQuestions: number
|
||||
lastAssessedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取学生在指定知识点集合上的掌握度。
|
||||
*
|
||||
* 供 textbooks 模块知识图谱学生视图使用,避免直查 knowledgePointMastery 表。
|
||||
*
|
||||
* @param studentId 学生 ID
|
||||
* @param knowledgePointIds 知识点 ID 列表
|
||||
* @returns Map<knowledgePointId, MasterySnapshot>
|
||||
*/
|
||||
export const getStudentMasteryByKpIdsRaw = async (
|
||||
studentId: string,
|
||||
knowledgePointIds: string[]
|
||||
): Promise<Map<string, MasterySnapshot>> => {
|
||||
const result = new Map<string, MasterySnapshot>()
|
||||
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({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
masteryLevel: knowledgePointMastery.masteryLevel,
|
||||
totalQuestions: knowledgePointMastery.totalQuestions,
|
||||
correctQuestions: knowledgePointMastery.correctQuestions,
|
||||
lastAssessedAt: knowledgePointMastery.lastAssessedAt,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.where(and(
|
||||
eq(knowledgePointMastery.studentId, studentId),
|
||||
inArray(knowledgePointMastery.knowledgePointId, uniqueIds),
|
||||
))
|
||||
|
||||
for (const r of rows) {
|
||||
result.set(r.knowledgePointId, {
|
||||
masteryLevel: Number(r.masteryLevel),
|
||||
totalQuestions: r.totalQuestions,
|
||||
correctQuestions: r.correctQuestions,
|
||||
lastAssessedAt: r.lastAssessedAt,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
export const getStudentMasteryByKpIds = cacheFn(getStudentMasteryByKpIdsRaw, {
|
||||
tags: ["diagnostic"],
|
||||
ttl: 300,
|
||||
keyParts: ["diagnostic", "getStudentMasteryByKpIds"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取多个学生在指定知识点集合上的聚合掌握度(按知识点聚合)。
|
||||
*
|
||||
* 供 textbooks 模块知识图谱班级视图使用,避免直查 knowledgePointMastery 表。
|
||||
* 聚合方式:AVG(masteryLevel) / SUM(totalQuestions) / SUM(correctQuestions) / MAX(lastAssessedAt)。
|
||||
*
|
||||
* @param studentIds 学生 ID 列表
|
||||
* @param knowledgePointIds 知识点 ID 列表
|
||||
* @returns Map<knowledgePointId, MasterySnapshot>
|
||||
*/
|
||||
export const getClassMasteryByKpIdsRaw = async (
|
||||
studentIds: string[],
|
||||
knowledgePointIds: string[]
|
||||
): Promise<Map<string, MasterySnapshot>> => {
|
||||
const result = new Map<string, MasterySnapshot>()
|
||||
const uniqueStudentIds = Array.from(new Set(studentIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const uniqueKpIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueStudentIds.length === 0 || uniqueKpIds.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
knowledgePointId: knowledgePointMastery.knowledgePointId,
|
||||
avgMastery: sql<number>`AVG(${knowledgePointMastery.masteryLevel})`,
|
||||
totalQuestions: sql<number>`SUM(${knowledgePointMastery.totalQuestions})`,
|
||||
correctQuestions: sql<number>`SUM(${knowledgePointMastery.correctQuestions})`,
|
||||
lastAssessedAt: sql<Date>`MAX(${knowledgePointMastery.lastAssessedAt})`,
|
||||
})
|
||||
.from(knowledgePointMastery)
|
||||
.where(and(
|
||||
inArray(knowledgePointMastery.studentId, uniqueStudentIds),
|
||||
inArray(knowledgePointMastery.knowledgePointId, uniqueKpIds),
|
||||
))
|
||||
.groupBy(knowledgePointMastery.knowledgePointId)
|
||||
|
||||
for (const r of rows) {
|
||||
result.set(r.knowledgePointId, {
|
||||
masteryLevel: Number(r.avgMastery),
|
||||
totalQuestions: Number(r.totalQuestions),
|
||||
correctQuestions: Number(r.correctQuestions),
|
||||
lastAssessedAt: r.lastAssessedAt,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
export const getClassMasteryByKpIds = cacheFn(getClassMasteryByKpIdsRaw, {
|
||||
tags: ["diagnostic"],
|
||||
ttl: 300,
|
||||
keyParts: ["diagnostic", "getClassMasteryByKpIds"],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -70,21 +70,21 @@ export function ElectiveCourseForm({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error(t("form.invalidFormState"))
|
||||
notify.error(t("form.invalidFormState"))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
// 根据 backHref 推断返回列表页路径
|
||||
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
|
||||
router.push(redirectBase)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("form.saveFailed"))
|
||||
notify.error(res.message || t("form.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("form.saveFailed"))
|
||||
notify.error(t("form.saveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -57,10 +57,10 @@ export function ElectiveCourseList({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await action(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? successMsg)
|
||||
notify.success(res.message ?? successMsg)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
notify.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -73,10 +73,10 @@ export function ElectiveCourseList({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await deleteElectiveCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? t("actions.delete"))
|
||||
notify.success(res.message ?? t("actions.delete"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
notify.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||
|
||||
@@ -70,7 +70,7 @@ export function StudentMySelectionsSection({
|
||||
}
|
||||
const res = await dropCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("student.dropSuccess"))
|
||||
notify.success(res.message || t("student.dropSuccess"))
|
||||
router.refresh()
|
||||
// 清空该课程的退课理由
|
||||
setDropReasonMap((prev) => {
|
||||
@@ -79,7 +79,7 @@ export function StudentMySelectionsSection({
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
notify.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
@@ -210,10 +210,10 @@ export function StudentAvailableCoursesSection({
|
||||
formData.set("courseId", courseId)
|
||||
const res = await selectCourseAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("student.selectSuccess"))
|
||||
notify.success(res.message || t("student.selectSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? t("errors.unexpected"))
|
||||
notify.error(res.message ?? t("errors.unexpected"))
|
||||
}
|
||||
setPendingId(null)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -14,8 +14,18 @@ import { sendNotification } from "@/modules/notifications"
|
||||
|
||||
import { getElectiveCreditLimit, getCapacityNotifyThreshold } from "./data-access-settings"
|
||||
import { getStudentGradeId } from "./data-access-selections"
|
||||
import {
|
||||
hasAnyScheduleConflict,
|
||||
parseSchedule,
|
||||
} from "./lib/schedule-conflict"
|
||||
import { buildLotteryRankCase, partitionLottery } from "./lib/lottery"
|
||||
import type { CourseSelectionStatus } from "./types"
|
||||
|
||||
// A-02 修复:纯函数已抽离至 lib/,此处 re-export 保持向后兼容
|
||||
// (tests/integration/elective/elective-pure-functions.test.ts 仍从本文件导入)
|
||||
export { normalizeDay, parseSchedule, isScheduleConflict } from "./lib/schedule-conflict"
|
||||
export { buildLotteryRankCase } from "./lib/lottery"
|
||||
|
||||
/**
|
||||
* 选课模块业务错误码(与 i18n key `errors.*` 对应)。
|
||||
* 由 actions 层根据 code 通过 getTranslations 翻译为用户可见文案。
|
||||
@@ -45,94 +55,12 @@ export class ElectiveBusinessError extends BusinessError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
|
||||
*/
|
||||
export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
const branches = ids.map(
|
||||
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
|
||||
)
|
||||
return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END`
|
||||
}
|
||||
|
||||
/**
|
||||
* 星期字符串归一化映射(纯函数,便于测试)。
|
||||
* 支持中英文全称与缩写,统一映射为 1-7 数字字符串。
|
||||
*/
|
||||
const DAY_NORMALIZE_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
// 中文
|
||||
"周一": "1", "周二": "2", "周三": "3", "周四": "4",
|
||||
"周五": "5", "周六": "6", "周日": "7", "周天": "7",
|
||||
"星期一": "1", "星期二": "2", "星期三": "3", "星期四": "4",
|
||||
"星期五": "5", "星期六": "6", "星期日": "7", "星期天": "7",
|
||||
// 英文全称
|
||||
monday: "1", tuesday: "2", wednesday: "3", thursday: "4",
|
||||
friday: "5", saturday: "6", sunday: "7",
|
||||
// 英文缩写
|
||||
mon: "1", tue: "2", wed: "3", thu: "4",
|
||||
fri: "5", sat: "6", sun: "7",
|
||||
})
|
||||
|
||||
export function normalizeDay(day: string): string {
|
||||
return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析课程 schedule 字段为可比较的时间段数组(纯函数,便于测试)。
|
||||
*
|
||||
* 支持多时段(以逗号或分号分隔),例如:
|
||||
* - "周一 14:00-15:30"
|
||||
* - "Mon 14:00-15:30, Wed 16:00-17:30"
|
||||
* - "周一 14:00-15:30;周三 16:00-17:30"
|
||||
*
|
||||
* 返回空数组表示无法解析(不参与冲突检测)。
|
||||
*/
|
||||
export function parseSchedule(
|
||||
schedule: string | null
|
||||
): Array<{ day: string; start: string; end: string }> {
|
||||
if (!schedule || schedule.length === 0) return []
|
||||
|
||||
// 按逗号、分号、中文分号拆分多个时段
|
||||
const segments = schedule.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean)
|
||||
const result: Array<{ day: string; start: string; end: string }> = []
|
||||
|
||||
// 支持中英文星期与英文全称/缩写
|
||||
const dayPattern = "周[一二三四五六日天]|星期[一二三四五六日天]|[Mm]on(?:day)?|[Tt]ue(?:sday)?|[Ww]ed(?:nesday)?|[Tt]hu(?:rsday)?|[Ff]ri(?:day)?|[Ss]at(?:urday)?|[Ss]un(?:day)?"
|
||||
const timePattern = "(\\d{1,2}):(\\d{2})"
|
||||
const re = new RegExp(
|
||||
`^(${dayPattern})\\s+${timePattern}\\s*[-~~至到]\\s*${timePattern}$`,
|
||||
"i"
|
||||
)
|
||||
|
||||
for (const seg of segments) {
|
||||
const match = seg.match(re)
|
||||
if (!match) continue
|
||||
const [, day, startH, startM, endH, endM] = match
|
||||
if (!day || !startH || !startM || !endH || !endM) continue
|
||||
result.push({
|
||||
day,
|
||||
start: `${startH.padStart(2, "0")}:${startM}`,
|
||||
end: `${endH.padStart(2, "0")}:${endM}`,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测两组时间段是否存在冲突(纯函数,便于测试)。
|
||||
* 仅当 day 相同(归一化后)且时间区间重叠时判定为冲突。
|
||||
*/
|
||||
export function isScheduleConflict(
|
||||
a: { day: string; start: string; end: string },
|
||||
b: { day: string; start: string; end: string }
|
||||
): boolean {
|
||||
if (normalizeDay(a.day) !== normalizeDay(b.day)) return false
|
||||
return a.start < b.end && b.start < a.end
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测学生选课时间冲突(P2 建议:选课时间冲突检测)。
|
||||
* 查询学生已选/已录取的课程,与新课程 schedule 比对。
|
||||
*
|
||||
* A-02 修复:时间段解析与冲突判定纯函数已抽离至 lib/schedule-conflict.ts,
|
||||
* 本函数仅保留 DB 查询(读取新课程 schedule + 学生已选课程 schedule)。
|
||||
*/
|
||||
async function checkScheduleConflict(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
@@ -162,14 +90,10 @@ async function checkScheduleConflict(
|
||||
|
||||
for (const row of existingCourses) {
|
||||
const existingSlots = parseSchedule(row.schedule)
|
||||
for (const newSlot of newSlots) {
|
||||
for (const existingSlot of existingSlots) {
|
||||
if (isScheduleConflict(newSlot, existingSlot)) {
|
||||
if (hasAnyScheduleConflict(newSlots, existingSlots)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -247,24 +171,10 @@ export async function runLottery(courseId: string): Promise<{
|
||||
return { enrolled: 0, waitlist: 0 }
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle: 无偏均匀随机排列,避免 sort(() => Math.random() - 0.5) 的分布偏差
|
||||
const shuffled = [...selections]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
// A-02 修复:洗牌 + 分桶算法已抽离至 lib/lottery.ts(partitionLottery)
|
||||
const capacity = course.capacity
|
||||
const now = new Date()
|
||||
|
||||
const enrolledIds: string[] = []
|
||||
const waitlistIds: string[] = []
|
||||
for (let i = 0; i < shuffled.length; i++) {
|
||||
if (i < capacity) {
|
||||
enrolledIds.push(shuffled[i].id)
|
||||
} else {
|
||||
waitlistIds.push(shuffled[i].id)
|
||||
}
|
||||
}
|
||||
const { enrolledIds, waitlistIds } = partitionLottery(selections, capacity)
|
||||
|
||||
const enrolledCount = enrolledIds.length
|
||||
const waitlistCount = waitlistIds.length
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
electiveCourses,
|
||||
} from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
|
||||
|
||||
import {
|
||||
buildCourseSelect,
|
||||
@@ -40,11 +41,6 @@ type SelectionCoreRow = {
|
||||
dropReason: string | null
|
||||
}
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapSelectionRow = (
|
||||
r: SelectionCoreRow,
|
||||
studentNames: Map<string, string | null>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { db } from "@/shared/db"
|
||||
import { electiveCourses } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
|
||||
import type {
|
||||
@@ -19,11 +20,6 @@ import type {
|
||||
} from "./schema"
|
||||
import { getCourseDisplayResolver } from "./resolvers"
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const buildScopeFilter = (scope: DataScope, userId?: string): SQL | null => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "owned" && userId) return eq(electiveCourses.teacherId, userId)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition, useEffect } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
@@ -71,7 +71,7 @@ export function AddErrorBookDialog({
|
||||
|
||||
function handleSubmit() {
|
||||
if (!questionId) {
|
||||
toast.error(t("messages.selectQuestion"))
|
||||
notify.error(t("messages.selectQuestion"))
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
@@ -82,13 +82,13 @@ export function AddErrorBookDialog({
|
||||
)
|
||||
const res = await createErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? t("messages.addedShort"))
|
||||
notify.success(res.message ?? t("messages.addedShort"))
|
||||
setOpen(false)
|
||||
setQuestionId("")
|
||||
setNote("")
|
||||
setErrorTags([])
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.addFailed"))
|
||||
notify.error(res.message ?? t("messages.addFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -71,9 +71,9 @@ export function ErrorBookDetailDialog({
|
||||
)
|
||||
const res = await updateErrorBookNoteAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.noteSaved"))
|
||||
notify.success(t("messages.noteSaved"))
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.saveFailed"))
|
||||
notify.error(res.message ?? t("messages.saveFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -84,10 +84,10 @@ export function ErrorBookDetailDialog({
|
||||
formData.append("itemId", item.id)
|
||||
const res = await archiveErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.archived"))
|
||||
notify.success(t("messages.archived"))
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.archiveFailed"))
|
||||
notify.error(res.message ?? t("messages.archiveFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -98,10 +98,10 @@ export function ErrorBookDetailDialog({
|
||||
formData.append("itemId", item.id)
|
||||
const res = await deleteErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.deleted"))
|
||||
notify.success(t("messages.deleted"))
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.deleteFailed"))
|
||||
notify.error(res.message ?? t("messages.deleteFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -65,10 +65,10 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
||||
formData.append("json", JSON.stringify({ itemId, result }))
|
||||
const res = await reviewErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? t("messages.reviewRecorded"))
|
||||
notify.success(res.message ?? t("messages.reviewRecorded"))
|
||||
onReviewed?.()
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.recordFailed"))
|
||||
notify.error(res.message ?? t("messages.recordFailed"))
|
||||
setSelected(null)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user