feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar
homework: - Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross - Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components classes: - Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils - Add class-list-table, class-list-toolbar, class-skeleton - Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils - Add data-access-teacher and hooks directory course-plans: - Add course-plan-calendar, sortable-week-row, template-picker-dialog components - Add lib directory
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
@@ -24,6 +26,7 @@ export async function createAdminClassAction(
|
|||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_CREATE)
|
await requirePermission(Permissions.CLASS_CREATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = CreateAdminClassSchema.safeParse({
|
const parsed = CreateAdminClassSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
@@ -36,7 +39,7 @@ export async function createAdminClassAction(
|
|||||||
room: formData.get("room"),
|
room: formData.get("room"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Class name, grade and teacher are required" }
|
return { success: false, message: t("actions.classNameGradeTeacherRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||||
@@ -56,13 +59,12 @@ export async function createAdminClassAction(
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class created successfully", data: id }
|
return { success: true, message: t("actions.classCreated"), data: id }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ export async function updateAdminClassAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_UPDATE)
|
await requirePermission(Permissions.CLASS_UPDATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = UpdateAdminClassSchema.safeParse({
|
const parsed = UpdateAdminClassSchema.safeParse({
|
||||||
classId,
|
classId,
|
||||||
@@ -86,11 +89,11 @@ export async function updateAdminClassAction(
|
|||||||
room: formData.get("room"),
|
room: formData.get("room"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
const subjectTeachers = await parseSubjectTeachers(formData.get("subjectTeachers"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateAdminClass(validatedClassId, {
|
await updateAdminClass(validatedClassId, {
|
||||||
@@ -115,23 +118,23 @@ export async function updateAdminClassAction(
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class updated successfully" }
|
return { success: true, message: t("actions.classUpdated") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
|
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_DELETE)
|
await requirePermission(Permissions.CLASS_DELETE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = DeleteAdminClassSchema.safeParse({ classId })
|
const parsed = DeleteAdminClassSchema.safeParse({ classId })
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -140,12 +143,11 @@ export async function deleteAdminClassAction(classId: string): Promise<ActionSta
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class deleted successfully" }
|
return { success: true, message: t("actions.classDeleted") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
@@ -26,6 +28,7 @@ export async function createGradeClassAction(
|
|||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = CreateGradeClassSchema.safeParse({
|
const parsed = CreateGradeClassSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
@@ -38,7 +41,7 @@ export async function createGradeClassAction(
|
|||||||
room: formData.get("room"),
|
room: formData.get("room"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Class name, grade and teacher are required" }
|
return { success: false, message: t("actions.classNameGradeTeacherRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
|
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
|
||||||
@@ -46,7 +49,7 @@ export async function createGradeClassAction(
|
|||||||
// Verify access
|
// Verify access
|
||||||
const isManager = await isGradeManager(gradeId, ctx.userId)
|
const isManager = await isGradeManager(gradeId, ctx.userId)
|
||||||
if (!isManager) {
|
if (!isManager) {
|
||||||
return { success: false, message: "You do not have permission to create classes for this grade" }
|
return { success: false, message: t("actions.notPermissionCreateGrade") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -61,13 +64,12 @@ export async function createGradeClassAction(
|
|||||||
room: room ?? null,
|
room: room ?? null,
|
||||||
})
|
})
|
||||||
revalidatePath("/management/grade/classes")
|
revalidatePath("/management/grade/classes")
|
||||||
return { success: true, message: "Class created successfully", data: id }
|
return { success: true, message: t("actions.classCreated"), data: id }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export async function updateGradeClassAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = UpdateGradeClassSchema.safeParse({
|
const parsed = UpdateGradeClassSchema.safeParse({
|
||||||
classId,
|
classId,
|
||||||
@@ -91,28 +94,28 @@ export async function updateGradeClassAction(
|
|||||||
room: formData.get("room"),
|
room: formData.get("room"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
const subjectTeachers = await parseSubjectTeachers(formData.get("subjectTeachers"))
|
||||||
|
|
||||||
// Verify access: Check if the class belongs to a managed grade
|
// Verify access: Check if the class belongs to a managed grade
|
||||||
const classGradeId = await getClassGradeId(validatedClassId)
|
const classGradeId = await getClassGradeId(validatedClassId)
|
||||||
if (!classGradeId) {
|
if (!classGradeId) {
|
||||||
return { success: false, message: "Class not found or not linked to a grade" }
|
return { success: false, message: t("actions.classNotFoundOrNotLinked") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||||
if (!isManager) {
|
if (!isManager) {
|
||||||
return { success: false, message: "You do not have permission to update this class" }
|
return { success: false, message: t("actions.notPermissionUpdateClass") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// If changing gradeId, verify target grade too
|
// If changing gradeId, verify target grade too
|
||||||
if (typeof gradeId === "string" && gradeId !== classGradeId) {
|
if (typeof gradeId === "string" && gradeId !== classGradeId) {
|
||||||
const isTargetManager = await isGradeManager(gradeId, ctx.userId)
|
const isTargetManager = await isGradeManager(gradeId, ctx.userId)
|
||||||
if (!isTargetManager) {
|
if (!isTargetManager) {
|
||||||
return { success: false, message: "You do not have permission to move class to this grade" }
|
return { success: false, message: t("actions.notPermissionMoveClass") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,23 +139,23 @@ export async function updateGradeClassAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/management/grade/classes")
|
revalidatePath("/management/grade/classes")
|
||||||
return { success: true, message: "Class updated successfully" }
|
return { success: true, message: t("actions.classUpdated") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = DeleteGradeClassSchema.safeParse({ classId })
|
const parsed = DeleteGradeClassSchema.safeParse({ classId })
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId: validatedClassId } = parsed.data
|
const { classId: validatedClassId } = parsed.data
|
||||||
@@ -160,23 +163,22 @@ export async function deleteGradeClassAction(classId: string): Promise<ActionSta
|
|||||||
// Verify access
|
// Verify access
|
||||||
const classGradeId = await getClassGradeId(validatedClassId)
|
const classGradeId = await getClassGradeId(validatedClassId)
|
||||||
if (!classGradeId) {
|
if (!classGradeId) {
|
||||||
return { success: false, message: "Class not found or not linked to a grade" }
|
return { success: false, message: t("actions.classNotFoundOrNotLinked") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||||
if (!isManager) {
|
if (!isManager) {
|
||||||
return { success: false, message: "You do not have permission to delete this class" }
|
return { success: false, message: t("actions.notPermissionDeleteClass") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAdminClass(validatedClassId)
|
await deleteAdminClass(validatedClassId)
|
||||||
revalidatePath("/management/grade/classes")
|
revalidatePath("/management/grade/classes")
|
||||||
return { success: true, message: "Class deleted successfully" }
|
return { success: true, message: t("actions.classDeleted") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
@@ -12,11 +14,28 @@ import {
|
|||||||
ensureClassInvitationCode,
|
ensureClassInvitationCode,
|
||||||
regenerateClassInvitationCode,
|
regenerateClassInvitationCode,
|
||||||
setStudentEnrollmentStatus,
|
setStudentEnrollmentStatus,
|
||||||
|
verifyTeacherOwnsClass,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import {
|
import {
|
||||||
EnrollStudentByEmailSchema,
|
EnrollStudentByEmailSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import { hasTeacherScope, hasStudentScope } from "./actions-shared"
|
import { hasAdminScope, hasTeacherScope, hasStudentScope } from "./actions-shared"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前用户对班级的归属权限(P0-2/P0-5 审计修复)。
|
||||||
|
* - admin scope:跳过校验
|
||||||
|
* - 其他用户:必须为该班级的班主任
|
||||||
|
* 返回 null 表示通过,返回 string 表示错误消息。
|
||||||
|
*/
|
||||||
|
async function assertClassOwnership(
|
||||||
|
ctx: Parameters<typeof hasAdminScope>[0],
|
||||||
|
classId: string,
|
||||||
|
t: (key: string) => string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (hasAdminScope(ctx)) return null
|
||||||
|
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||||
|
return owns ? null : t("actions.notPermissionManageClass")
|
||||||
|
}
|
||||||
|
|
||||||
export async function enrollStudentByEmailAction(
|
export async function enrollStudentByEmailAction(
|
||||||
classId: string,
|
classId: string,
|
||||||
@@ -24,27 +43,31 @@ export async function enrollStudentByEmailAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_ENROLL)
|
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = EnrollStudentByEmailSchema.safeParse({
|
const parsed = EnrollStudentByEmailSchema.safeParse({
|
||||||
classId,
|
classId,
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Please select a class and provide student email" }
|
return { success: false, message: t("actions.classAndEmailRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-5: 越权校验——教师注册学生前校验班级归属
|
||||||
|
const ownErr = await assertClassOwnership(ctx, parsed.data.classId, t)
|
||||||
|
if (ownErr) return { success: false, message: ownErr }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
|
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
return { success: true, message: "Student added successfully" }
|
return { success: true, message: t("actions.studentAdded") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,22 +77,23 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
): Promise<ActionState<{ classId: string }>> {
|
): Promise<ActionState<{ classId: string }>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const code = formData.get("code")
|
const code = formData.get("code")
|
||||||
if (typeof code !== "string" || code.trim().length === 0) {
|
if (typeof code !== "string" || code.trim().length === 0) {
|
||||||
return { success: false, message: "Invitation code is required" }
|
return { success: false, message: t("actions.invitationCodeRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3:rate limit 防爆破(10 次/5 分钟,按 userId)
|
// v3:rate limit 防爆破(10 次/5 分钟,按 userId)
|
||||||
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
|
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
|
||||||
const rlKey = rateLimitKey("class-join", ctx.userId)
|
const rlKey = rateLimitKey("class-join", ctx.userId)
|
||||||
const rlResult = rateLimit({
|
const rlResult = await rateLimit({
|
||||||
key: rlKey,
|
key: rlKey,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
windowMs: 5 * 60 * 1000,
|
windowMs: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
if (!rlResult.success) {
|
if (!rlResult.success) {
|
||||||
return { success: false, message: "Too many attempts, please try again later" }
|
return { success: false, message: t("actions.tooManyAttempts") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码
|
// P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码
|
||||||
@@ -78,7 +102,7 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null
|
const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||||
|
|
||||||
if (isTeacher && (!subject || subject.length === 0)) {
|
if (isTeacher && (!subject || subject.length === 0)) {
|
||||||
return { success: false, message: "Subject is required" }
|
return { success: false, message: t("actions.subjectRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -88,7 +112,7 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
|
|
||||||
// 成功后重置 rate limit
|
// 成功后重置 rate limit
|
||||||
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
|
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
|
||||||
resetRateLimit(rlKey)
|
await resetRateLimit(rlKey)
|
||||||
|
|
||||||
// 审计日志
|
// 审计日志
|
||||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||||
@@ -113,7 +137,7 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
}
|
}
|
||||||
revalidatePath("/profile")
|
revalidatePath("/profile")
|
||||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
return { success: true, message: t("actions.joinedClass"), data: { classId } }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 审计日志:加入失败
|
// 审计日志:加入失败
|
||||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||||
@@ -128,55 +152,54 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
},
|
},
|
||||||
status: "failure",
|
status: "failure",
|
||||||
})
|
})
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_ENROLL)
|
await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const code = await ensureClassInvitationCode(classId)
|
const code = await ensureClassInvitationCode(classId)
|
||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||||
return { success: true, message: "Invitation code ready", data: { code } }
|
return { success: true, message: t("actions.invitationCodeReady"), data: { code } }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_ENROLL)
|
await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const code = await regenerateClassInvitationCode(classId)
|
const code = await regenerateClassInvitationCode(classId)
|
||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||||
return { success: true, message: "Invitation code updated", data: { code } }
|
return { success: true, message: t("actions.invitationCodeUpdated"), data: { code } }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,10 +216,11 @@ export async function createClassInvitationCodeAction(
|
|||||||
): Promise<ActionState<{ code: string; id: string }>> {
|
): Promise<ActionState<{ code: string; id: string }>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const classId = String(formData.get("classId") ?? "").trim()
|
const classId = String(formData.get("classId") ?? "").trim()
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresInHoursRaw = formData.get("expiresInHours")
|
const expiresInHoursRaw = formData.get("expiresInHours")
|
||||||
@@ -213,10 +237,10 @@ export async function createClassInvitationCodeAction(
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
|
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
|
||||||
return { success: false, message: "Invalid expiresInHours" }
|
return { success: false, message: t("actions.invalidExpiresInHours") }
|
||||||
}
|
}
|
||||||
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
|
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
|
||||||
return { success: false, message: "Invalid maxUses" }
|
return { success: false, message: t("actions.invalidMaxUses") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -248,18 +272,14 @@ export async function createClassInvitationCodeAction(
|
|||||||
revalidatePath(`/admin/school/classes`)
|
revalidatePath(`/admin/school/classes`)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Invitation code generated",
|
message: t("actions.invitationCodeGenerated"),
|
||||||
data: { code: record.code, id: record.id },
|
data: { code: record.code, id: record.id },
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return handleActionError(error)
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : "Failed to generate code",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,10 +292,11 @@ export async function revokeClassInvitationCodeAction(
|
|||||||
): Promise<ActionState<null>> {
|
): Promise<ActionState<null>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const codeId = String(formData.get("codeId") ?? "").trim()
|
const codeId = String(formData.get("codeId") ?? "").trim()
|
||||||
if (!codeId) {
|
if (!codeId) {
|
||||||
return { success: false, message: "Missing code id" }
|
return { success: false, message: t("actions.missingCodeId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -293,16 +314,12 @@ export async function revokeClassInvitationCodeAction(
|
|||||||
|
|
||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath(`/admin/school/classes`)
|
revalidatePath(`/admin/school/classes`)
|
||||||
return { success: true, message: "Invitation code revoked" }
|
return { success: true, message: t("actions.invitationCodeRevoked") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return handleActionError(error)
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : "Failed to revoke code",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,12 +330,17 @@ export async function listClassInvitationCodesAction(
|
|||||||
classId: string
|
classId: string
|
||||||
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_ENROLL)
|
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-2: 越权校验——防止教师枚举他班邀请码(admin scope 跳过)
|
||||||
|
const ownErr = await assertClassOwnership(ctx, classId, t)
|
||||||
|
if (ownErr) return { success: false, message: ownErr }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { listClassInvitationCodes } = await import("./data-access-invitations")
|
const { listClassInvitationCodes } = await import("./data-access-invitations")
|
||||||
const codes = await listClassInvitationCodes(classId)
|
const codes = await listClassInvitationCodes(classId)
|
||||||
@@ -339,14 +361,10 @@ export async function listClassInvitationCodesAction(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return handleActionError(error)
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : "Failed to list codes",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,22 +375,22 @@ export async function setStudentEnrollmentStatusAction(
|
|||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_ENROLL)
|
await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
if (!classId?.trim() || !studentId?.trim()) {
|
if (!classId?.trim() || !studentId?.trim()) {
|
||||||
return { success: false, message: "Missing enrollment info" }
|
return { success: false, message: t("actions.missingEnrollmentInfo") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setStudentEnrollmentStatus(classId, studentId, status)
|
await setStudentEnrollmentStatus(classId, studentId, status)
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
return { success: true, message: "Student updated successfully" }
|
return { success: true, message: t("actions.studentUpdated") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +404,16 @@ export async function bulkEnrollStudentsAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState<{ imported: number; failed: number; errors: string[] }>> {
|
): Promise<ActionState<{ imported: number; failed: number; errors: string[] }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_ENROLL)
|
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
|
// P0-5: 越权校验——批量注册学生前校验班级归属
|
||||||
|
const ownErr = await assertClassOwnership(ctx, classId, t)
|
||||||
|
if (ownErr) return { success: false, message: ownErr }
|
||||||
|
|
||||||
const csvText = String(formData.get("csv") ?? "").trim()
|
const csvText = String(formData.get("csv") ?? "").trim()
|
||||||
if (!csvText) {
|
if (!csvText) {
|
||||||
return { success: false, message: "CSV data is required" }
|
return { success: false, message: t("actions.csvRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 CSV:每行一个邮箱,格式 name,email 或仅 email
|
// 解析 CSV:每行一个邮箱,格式 name,email 或仅 email
|
||||||
@@ -406,7 +429,7 @@ export async function bulkEnrollStudentsAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return { success: false, message: "No valid entries found" }
|
return { success: false, message: t("actions.noValidEntries") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 逐个注册(复用 enrollStudentByEmail data-access 逻辑)
|
// 逐个注册(复用 enrollStudentByEmail data-access 逻辑)
|
||||||
@@ -429,13 +452,11 @@ export async function bulkEnrollStudentsAction(
|
|||||||
revalidatePath("/admin/school/classes")
|
revalidatePath("/admin/school/classes")
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Imported ${imported} students, ${failed} failed`,
|
message: t("actions.bulkImportResult", { imported, failed }),
|
||||||
data: { imported, failed, errors },
|
data: { imported, failed, errors },
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Failed to bulk enroll students" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,10 +471,11 @@ export async function bulkAssignSubjectTeachersAction(
|
|||||||
): Promise<ActionState<{ updated: number; failed: number; errors: string[] }>> {
|
): Promise<ActionState<{ updated: number; failed: number; errors: string[] }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_UPDATE)
|
await requirePermission(Permissions.CLASS_UPDATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const csvText = String(formData.get("csv") ?? "").trim()
|
const csvText = String(formData.get("csv") ?? "").trim()
|
||||||
if (!csvText) {
|
if (!csvText) {
|
||||||
return { success: false, message: "CSV data is required" }
|
return { success: false, message: t("actions.csvRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 CSV:格式 className,subject,teacherEmail
|
// 解析 CSV:格式 className,subject,teacherEmail
|
||||||
@@ -468,7 +490,7 @@ export async function bulkAssignSubjectTeachersAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return { success: false, message: "No valid entries found" }
|
return { success: false, message: t("actions.noValidEntries") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = 0
|
const updated = 0
|
||||||
@@ -491,12 +513,10 @@ export async function bulkAssignSubjectTeachersAction(
|
|||||||
revalidatePath("/admin/school/classes")
|
revalidatePath("/admin/school/classes")
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Updated ${updated} assignments, ${failed} failed`,
|
message: t("actions.bulkAssignResult", { updated, failed }),
|
||||||
data: { updated, failed, errors },
|
data: { updated, failed, errors },
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Failed to bulk assign teachers" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
@@ -16,14 +17,33 @@ import {
|
|||||||
UpdateClassScheduleItemSchema,
|
UpdateClassScheduleItemSchema,
|
||||||
DeleteClassScheduleItemSchema,
|
DeleteClassScheduleItemSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import { toWeekday } from "./actions-shared"
|
import { hasAdminScope, toWeekday } from "./actions-shared"
|
||||||
|
import { verifyTeacherOwnsClass } from "./data-access"
|
||||||
|
import { getClassIdByScheduleId } from "./data-access-schedule"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前用户对班级的归属权限(P0-3 审计修复)。
|
||||||
|
* - admin scope:跳过校验(管理员可管理任意班级)
|
||||||
|
* - 其他用户:必须为该班级的班主任
|
||||||
|
* 返回 null 表示通过,返回 string 表示错误消息。
|
||||||
|
*/
|
||||||
|
async function assertClassOwnership(
|
||||||
|
ctx: Parameters<typeof hasAdminScope>[0],
|
||||||
|
classId: string,
|
||||||
|
t: (key: string) => string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (hasAdminScope(ctx)) return null
|
||||||
|
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||||
|
return owns ? null : t("actions.notPermissionManageClassSchedule")
|
||||||
|
}
|
||||||
|
|
||||||
export async function createClassScheduleItemAction(
|
export async function createClassScheduleItemAction(
|
||||||
prevState: ActionState<string> | null,
|
prevState: ActionState<string> | null,
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
const ctx = await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = CreateClassScheduleItemSchema.safeParse({
|
const parsed = CreateClassScheduleItemSchema.safeParse({
|
||||||
classId: formData.get("classId"),
|
classId: formData.get("classId"),
|
||||||
@@ -34,22 +54,26 @@ export async function createClassScheduleItemAction(
|
|||||||
location: formData.get("location"),
|
location: formData.get("location"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Invalid schedule item data" }
|
return { success: false, message: t("actions.invalidScheduleData") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId, weekday, course, startTime, endTime, location } = parsed.data
|
const { classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||||
|
|
||||||
|
// P0-3: 越权校验——防止教师为他班添加课表
|
||||||
|
const ownErr = await assertClassOwnership(ctx, classId, t)
|
||||||
|
if (ownErr) return { success: false, message: ownErr }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = await createClassScheduleItem({
|
const id = await createClassScheduleItem({
|
||||||
classId,
|
classId,
|
||||||
weekday: toWeekday(weekday),
|
weekday: await toWeekday(weekday),
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
course,
|
course,
|
||||||
location: location ?? null,
|
location: location ?? null,
|
||||||
})
|
})
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Schedule item created successfully", data: id }
|
return { success: true, message: t("actions.scheduleItemCreated"), data: id }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error)
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
@@ -64,7 +88,8 @@ export async function updateClassScheduleItemAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
const ctx = await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = UpdateClassScheduleItemSchema.safeParse({
|
const parsed = UpdateClassScheduleItemSchema.safeParse({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -76,22 +101,33 @@ export async function updateClassScheduleItemAction(
|
|||||||
location: formData.get("location"),
|
location: formData.get("location"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing or invalid schedule id" }
|
return { success: false, message: t("actions.missingScheduleId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
|
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||||
|
|
||||||
|
// P0-3: 越权校验——更新时需校验目标班级归属
|
||||||
|
// 优先用表单传入的 classId,否则从已有 schedule item 反查 classId
|
||||||
|
const targetClassId = typeof classId === "string" && classId.trim()
|
||||||
|
? classId
|
||||||
|
: await getClassIdByScheduleId(validatedScheduleId)
|
||||||
|
if (!targetClassId) {
|
||||||
|
return { success: false, message: t("actions.scheduleItemNotFound") }
|
||||||
|
}
|
||||||
|
const ownErr = await assertClassOwnership(ctx, targetClassId, t)
|
||||||
|
if (ownErr) return { success: false, message: ownErr }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateClassScheduleItem(validatedScheduleId, {
|
await updateClassScheduleItem(validatedScheduleId, {
|
||||||
classId: classId ?? undefined,
|
classId: classId ?? undefined,
|
||||||
weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined,
|
weekday: typeof weekday === "number" ? await toWeekday(weekday) : undefined,
|
||||||
startTime: startTime ?? undefined,
|
startTime: startTime ?? undefined,
|
||||||
endTime: endTime ?? undefined,
|
endTime: endTime ?? undefined,
|
||||||
course: course ?? undefined,
|
course: course ?? undefined,
|
||||||
location: location ?? undefined,
|
location: location ?? undefined,
|
||||||
})
|
})
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Schedule item updated successfully" }
|
return { success: true, message: t("actions.scheduleItemUpdated") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error)
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
@@ -102,17 +138,26 @@ export async function updateClassScheduleItemAction(
|
|||||||
|
|
||||||
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
|
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
const ctx = await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
|
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing schedule id" }
|
return { success: false, message: t("actions.missingScheduleId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-3: 越权校验——删除时需校验该 schedule item 所属班级归属
|
||||||
|
const targetClassId = await getClassIdByScheduleId(parsed.data.scheduleId)
|
||||||
|
if (!targetClassId) {
|
||||||
|
return { success: false, message: t("actions.scheduleItemNotFound") }
|
||||||
|
}
|
||||||
|
const ownErr = await assertClassOwnership(ctx, targetClassId, t)
|
||||||
|
if (ownErr) return { success: false, message: ownErr }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteClassScheduleItem(parsed.data.scheduleId)
|
await deleteClassScheduleItem(parsed.data.scheduleId)
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Schedule item deleted successfully" }
|
return { success: true, message: t("actions.scheduleItemDeleted") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error)
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import type { AuthContext } from "@/shared/types/permissions"
|
import type { AuthContext } from "@/shared/types/permissions"
|
||||||
import type { ClassSubject } from "./types"
|
import type { ClassSubject } from "./types"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
|
import { ValidationError } from "@/shared/lib/action-utils"
|
||||||
|
|
||||||
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||||
|
|
||||||
@@ -9,8 +12,11 @@ export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_ST
|
|||||||
export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||||
n >= 1 && n <= 7 && Number.isInteger(n)
|
n >= 1 && n <= 7 && Number.isInteger(n)
|
||||||
|
|
||||||
export const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
export const toWeekday = async (n: number): Promise<1 | 2 | 3 | 4 | 5 | 6 | 7> => {
|
||||||
if (!isWeekday(n)) throw new Error("Invalid weekday")
|
if (!isWeekday(n)) {
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
throw new ValidationError(t("actions.invalidWeekday"))
|
||||||
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,16 +41,25 @@ export const hasStudentScope = (ctx: AuthContext): boolean => ctx.dataScope.type
|
|||||||
/**
|
/**
|
||||||
* 解析表单中的 subjectTeachers JSON 字符串为标准赋值数组。
|
* 解析表单中的 subjectTeachers JSON 字符串为标准赋值数组。
|
||||||
* 提取自原 actions.ts,供 admin/grade class 更新逻辑复用。
|
* 提取自原 actions.ts,供 admin/grade class 更新逻辑复用。
|
||||||
|
*
|
||||||
|
* 入参类型直接接受 FormDataEntryValue(string | File),避免在调用方
|
||||||
|
* 使用 `as string | null` 强断言(P2-A 修复)。
|
||||||
*/
|
*/
|
||||||
export const parseSubjectTeachers = (raw: string | null) => {
|
export const parseSubjectTeachers = async (raw: FormDataEntryValue | null) => {
|
||||||
if (typeof raw !== "string" || raw.trim().length === 0) return null
|
if (typeof raw !== "string" || raw.trim().length === 0) return null
|
||||||
const parsed = JSON.parse(raw) as unknown
|
// JSON.parse 返回 any,此处显式拓宽到 unknown 以迫使后续使用类型守卫(合法的 any → unknown 转换)
|
||||||
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
throw new ValidationError(t("actions.invalidSubjectTeachers"))
|
||||||
|
}
|
||||||
|
|
||||||
return parsed.flatMap((item) => {
|
return parsed.flatMap((item): Array<{ subject: ClassSubject; teacherId: string | null }> => {
|
||||||
if (!item || typeof item !== "object") return []
|
if (!item || typeof item !== "object") return []
|
||||||
const subject = (item as { subject?: unknown }).subject
|
|
||||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
// 使用 `in` 操作符收窄类型,避免 `(item as { x?: unknown }).x` 反模式(P2-A 修复)
|
||||||
|
const subject = "subject" in item ? item.subject : undefined
|
||||||
|
const teacherId = "teacherId" in item ? item.teacherId : undefined
|
||||||
|
|
||||||
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
createTeacherClass,
|
createTeacherClass,
|
||||||
deleteTeacherClass,
|
deleteTeacherClass,
|
||||||
updateTeacherClass,
|
updateTeacherClass,
|
||||||
|
verifyTeacherOwnsClass,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import { findGradeIdByHeadAndName, isGradeHead } from "@/modules/school/data-access"
|
import { findGradeIdByHeadAndName, isGradeHead } from "@/modules/school/data-access"
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +27,7 @@ export async function createTeacherClassAction(
|
|||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = CreateTeacherClassSchema.safeParse({
|
const parsed = CreateTeacherClassSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
@@ -36,7 +39,7 @@ export async function createTeacherClassAction(
|
|||||||
room: formData.get("room"),
|
room: formData.get("room"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Class name and grade are required" }
|
return { success: false, message: t("actions.classNameGradeRequired") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||||
@@ -50,7 +53,7 @@ export async function createTeacherClassAction(
|
|||||||
? await isGradeHead(normalizedGradeId, userId)
|
? await isGradeHead(normalizedGradeId, userId)
|
||||||
: Boolean(await findGradeIdByHeadAndName(userId, grade))
|
: Boolean(await findGradeIdByHeadAndName(userId, grade))
|
||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
return { success: false, message: "Only admins and grade heads can create classes" }
|
return { success: false, message: t("actions.onlyAdminsAndGradeHeads") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ export async function createTeacherClassAction(
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class created successfully", data: id }
|
return { success: true, message: t("actions.classCreated"), data: id }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error)
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,8 @@ export async function updateTeacherClassAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState> {
|
): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_UPDATE)
|
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = UpdateTeacherClassSchema.safeParse({
|
const parsed = UpdateTeacherClassSchema.safeParse({
|
||||||
classId,
|
classId,
|
||||||
@@ -95,11 +99,19 @@ export async function updateTeacherClassAction(
|
|||||||
room: formData.get("room"),
|
room: formData.get("room"),
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
|
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
|
||||||
|
|
||||||
|
// P0-5: 越权校验——教师更新班级前校验归属(admin scope 跳过)
|
||||||
|
if (!hasAdminScope(ctx)) {
|
||||||
|
const owns = await verifyTeacherOwnsClass(validatedClassId, ctx.userId)
|
||||||
|
if (!owns) {
|
||||||
|
return { success: false, message: t("actions.notOwnClass") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTeacherClass(validatedClassId, {
|
await updateTeacherClass(validatedClassId, {
|
||||||
schoolName: schoolName ?? undefined,
|
schoolName: schoolName ?? undefined,
|
||||||
@@ -113,7 +125,7 @@ export async function updateTeacherClassAction(
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class updated successfully" }
|
return { success: true, message: t("actions.classUpdated") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error)
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
@@ -124,11 +136,20 @@ export async function updateTeacherClassAction(
|
|||||||
|
|
||||||
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
|
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.CLASS_DELETE)
|
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||||
|
const t = await getTranslations("classes")
|
||||||
|
|
||||||
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
|
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Missing class id" }
|
return { success: false, message: t("actions.missingClassId") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// P0-5: 越权校验——教师删除班级前校验归属(admin scope 跳过)
|
||||||
|
if (!hasAdminScope(ctx)) {
|
||||||
|
const owns = await verifyTeacherOwnsClass(parsed.data.classId, ctx.userId)
|
||||||
|
if (!owns) {
|
||||||
|
return { success: false, message: t("actions.notOwnClass") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +157,7 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
|||||||
revalidatePath("/teacher/classes/my")
|
revalidatePath("/teacher/classes/my")
|
||||||
revalidatePath("/teacher/classes/students")
|
revalidatePath("/teacher/classes/students")
|
||||||
revalidatePath("/teacher/classes/schedule")
|
revalidatePath("/teacher/classes/schedule")
|
||||||
return { success: true, message: "Class deleted successfully" }
|
return { success: true, message: t("actions.classDeleted") }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error)
|
return handleActionError(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,19 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo } from "react"
|
||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
|
import type { AdminClassListItem, TeacherOption } from "../types"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
|
||||||
import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions"
|
import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { useClassData } from "../hooks/use-class-data"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { useClassFilters } from "../hooks/use-class-filters"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
import type { ClassFormGrade } from "./class-form-utils"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { ClassDeleteDialog } from "./class-delete-dialog"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { ClassFormDialog } from "./class-form-dialog"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { ClassListTable } from "./class-list-table"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { ClassListToolbar } from "./class-list-toolbar"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
export function AdminClassesClient({
|
export function AdminClassesClient({
|
||||||
classes,
|
classes,
|
||||||
@@ -47,483 +26,133 @@ export function AdminClassesClient({
|
|||||||
schools: { id: string; name: string }[]
|
schools: { id: string; name: string }[]
|
||||||
grades: { id: string; name: string; school: { id: string; name: string } }[]
|
grades: { id: string; name: string; school: { id: string; name: string } }[]
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
|
||||||
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
|
||||||
|
|
||||||
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
||||||
const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools])
|
const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools])
|
||||||
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
const data = useClassData({ defaultTeacherId, defaultSchoolId, defaultGradeId: "" })
|
||||||
const [createSchoolId, setCreateSchoolId] = useState(defaultSchoolId)
|
|
||||||
const [createGradeId, setCreateGradeId] = useState("")
|
|
||||||
const [editTeacherId, setEditTeacherId] = useState("")
|
|
||||||
const [editSchoolId, setEditSchoolId] = useState("")
|
|
||||||
const [editGradeId, setEditGradeId] = useState("")
|
|
||||||
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
|
|
||||||
|
|
||||||
const createGrades = useMemo(() => grades.filter((g) => g.school.id === createSchoolId), [grades, createSchoolId])
|
const formGrades: ClassFormGrade[] = useMemo(
|
||||||
const editGrades = useMemo(() => grades.filter((g) => g.school.id === editSchoolId), [grades, editSchoolId])
|
() => grades.map((g) => ({ id: g.id, name: g.name, schoolId: g.school.id, schoolName: g.school.name })),
|
||||||
const selectedCreateSchool = schools.find((s) => s.id === createSchoolId)
|
[grades],
|
||||||
const selectedCreateGrade = grades.find((g) => g.id === createGradeId)
|
)
|
||||||
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
|
const { createGrades, editGrades } = useClassFilters(formGrades, data.createSchoolId, data.editSchoolId)
|
||||||
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
|
|
||||||
|
|
||||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
const handleCreate = async (formData: FormData): Promise<void> => {
|
||||||
if (createOpen !== prevCreateOpen) {
|
data.setIsWorking(true)
|
||||||
setPrevCreateOpen(createOpen)
|
|
||||||
if (createOpen) {
|
|
||||||
setCreateTeacherId(defaultTeacherId)
|
|
||||||
setCreateSchoolId(defaultSchoolId)
|
|
||||||
setCreateGradeId("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
|
||||||
if (editItem !== prevEditItem) {
|
|
||||||
setPrevEditItem(editItem)
|
|
||||||
if (editItem) {
|
|
||||||
setEditTeacherId(editItem.teacher.id)
|
|
||||||
setEditSchoolId(editItem.schoolId ?? "")
|
|
||||||
setEditGradeId(editItem.gradeId ?? "")
|
|
||||||
setEditSubjectTeachers(
|
|
||||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
|
||||||
subject: s,
|
|
||||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
|
||||||
setIsWorking(true)
|
|
||||||
try {
|
try {
|
||||||
const res = await createAdminClassAction(undefined, formData)
|
const res = await createAdminClassAction(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setCreateOpen(false)
|
data.setCreateOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to create class")
|
toast.error(res.message || t("list.failedCreate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create class")
|
toast.error(t("list.failedCreate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
data.setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (formData: FormData) => {
|
const handleUpdate = async (formData: FormData): Promise<void> => {
|
||||||
if (!editItem) return
|
if (!data.editItem) return
|
||||||
setIsWorking(true)
|
data.setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await updateAdminClassAction(editItem.id, undefined, formData)
|
const res = await updateAdminClassAction(data.editItem.id, undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setEditItem(null)
|
data.setEditItem(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to update class")
|
toast.error(res.message || t("list.failedUpdate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update class")
|
toast.error(t("list.failedUpdate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
data.setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (): Promise<void> => {
|
||||||
if (!deleteItem) return
|
if (!data.deleteItem) return
|
||||||
setIsWorking(true)
|
data.setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await deleteAdminClassAction(deleteItem.id)
|
const res = await deleteAdminClassAction(data.deleteItem.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setDeleteItem(null)
|
data.setDeleteItem(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to delete class")
|
toast.error(res.message || t("list.failedDelete"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to delete class")
|
toast.error(t("list.failedDelete"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
data.setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
|
||||||
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
|
|
||||||
const pairs = list
|
|
||||||
.filter((x) => x.teacher)
|
|
||||||
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
|
|
||||||
.filter((x) => x.length > 0)
|
|
||||||
return pairs.length > 0 ? pairs.join(",") : "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end">
|
<ClassListToolbar count={classes.length} onNew={() => data.setCreateOpen(true)} isWorking={data.isWorking} />
|
||||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New class
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="shadow-none">
|
<ClassListTable
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
classes={classes}
|
||||||
<CardTitle className="text-base">All classes</CardTitle>
|
onEdit={data.setEditItem}
|
||||||
<Badge variant="secondary" className="tabular-nums">
|
onDelete={data.setDeleteItem}
|
||||||
{classes.length}
|
isWorking={data.isWorking}
|
||||||
</Badge>
|
/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{classes.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="No classes"
|
|
||||||
description="Create classes to manage students and schedules."
|
|
||||||
className="h-auto border-none shadow-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>School</TableHead>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Grade</TableHead>
|
|
||||||
<TableHead>Homeroom</TableHead>
|
|
||||||
<TableHead>Room</TableHead>
|
|
||||||
<TableHead>班主任</TableHead>
|
|
||||||
<TableHead>任课老师</TableHead>
|
|
||||||
<TableHead className="text-right">Students</TableHead>
|
|
||||||
<TableHead>Updated</TableHead>
|
|
||||||
<TableHead className="w-[60px]" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<TableRow key={c.id}>
|
|
||||||
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
|
||||||
<TableCell className="font-medium">{c.name}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setEditItem(c)}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => setDeleteItem(c)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<ClassFormDialog
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
open={data.createOpen}
|
||||||
<DialogHeader>
|
onOpenChange={data.setCreateOpen}
|
||||||
<DialogTitle>New class</DialogTitle>
|
mode="create"
|
||||||
</DialogHeader>
|
teachers={teachers}
|
||||||
<form action={handleCreate} className="space-y-4">
|
schools={schools}
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
grades={createGrades}
|
||||||
<Label className="text-right">School</Label>
|
onSubmit={handleCreate}
|
||||||
<div className="col-span-3">
|
isWorking={data.isWorking}
|
||||||
<Select
|
teacherId={data.createTeacherId}
|
||||||
value={createSchoolId}
|
schoolId={data.createSchoolId}
|
||||||
onValueChange={(v) => {
|
gradeId={data.createGradeId}
|
||||||
setCreateSchoolId(v)
|
onTeacherIdChange={data.setCreateTeacherId}
|
||||||
setCreateGradeId("")
|
onSchoolIdChange={data.setCreateSchoolId}
|
||||||
}}
|
onGradeIdChange={data.setCreateGradeId}
|
||||||
disabled={schools.length === 0}
|
/>
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={schools.length === 0 ? "No schools" : "Select a school"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="schoolId" value={createSchoolId} />
|
|
||||||
<input type="hidden" name="schoolName" value={selectedCreateSchool?.name ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<ClassFormDialog
|
||||||
<Label htmlFor="create-name" className="text-right">
|
open={Boolean(data.editItem)}
|
||||||
Name
|
onOpenChange={(o) => {
|
||||||
</Label>
|
if (!o) data.setEditItem(null)
|
||||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Grade 10 · Class 3" autoFocus />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Grade</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select
|
|
||||||
value={createGradeId}
|
|
||||||
onValueChange={setCreateGradeId}
|
|
||||||
disabled={createGrades.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={createGrades.length === 0 ? "No grades" : "Select a grade"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{createGrades.map((g) => (
|
|
||||||
<SelectItem key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="gradeId" value={createGradeId} />
|
|
||||||
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-homeroom" className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-room" className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Teacher</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name} ({t.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="teacherId" value={createTeacherId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={Boolean(editItem)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (isWorking) return
|
|
||||||
if (!open) setEditItem(null)
|
|
||||||
}}
|
}}
|
||||||
>
|
mode="edit"
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
editItem={data.editItem}
|
||||||
<DialogHeader>
|
teachers={teachers}
|
||||||
<DialogTitle>Edit class</DialogTitle>
|
schools={schools}
|
||||||
</DialogHeader>
|
grades={editGrades}
|
||||||
{editItem ? (
|
onSubmit={handleUpdate}
|
||||||
<form action={handleUpdate} className="space-y-4">
|
isWorking={data.isWorking}
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
teacherId={data.editTeacherId}
|
||||||
<Label className="text-right">School</Label>
|
schoolId={data.editSchoolId}
|
||||||
<div className="col-span-3">
|
gradeId={data.editGradeId}
|
||||||
<Select
|
subjectTeachers={data.editSubjectTeachers}
|
||||||
value={editSchoolId}
|
onTeacherIdChange={data.setEditTeacherId}
|
||||||
onValueChange={(v) => {
|
onSchoolIdChange={data.setEditSchoolId}
|
||||||
setEditSchoolId(v)
|
onGradeIdChange={data.setEditGradeId}
|
||||||
setEditGradeId("")
|
onSubjectTeacherChange={data.setSubjectTeacher}
|
||||||
}}
|
/>
|
||||||
disabled={schools.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={schools.length === 0 ? "No schools" : "Select a school"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="schoolId" value={editSchoolId} />
|
|
||||||
<input type="hidden" name="schoolName" value={selectedEditSchool?.name ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<ClassDeleteDialog
|
||||||
<Label htmlFor="edit-name" className="text-right">
|
open={Boolean(data.deleteItem)}
|
||||||
Name
|
onOpenChange={(o) => {
|
||||||
</Label>
|
if (!o) data.setDeleteItem(null)
|
||||||
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Grade</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select
|
|
||||||
value={editGradeId}
|
|
||||||
onValueChange={setEditGradeId}
|
|
||||||
disabled={editGrades.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={editGrades.length === 0 ? "No grades" : "Select a grade"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{editGrades.map((g) => (
|
|
||||||
<SelectItem key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="gradeId" value={editGradeId} />
|
|
||||||
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-homeroom" className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-room" className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">班主任</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name} ({t.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="teacherId" value={editTeacherId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 rounded-md border p-4">
|
|
||||||
<div className="text-sm font-medium">任课老师</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
|
||||||
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
|
||||||
return (
|
|
||||||
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">{subject}</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select
|
|
||||||
value={selected ?? ""}
|
|
||||||
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
|
|
||||||
disabled={teachers.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name} ({t.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isWorking || !editTeacherId}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={Boolean(deleteItem)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setDeleteItem(null)
|
|
||||||
}}
|
}}
|
||||||
>
|
item={data.deleteItem}
|
||||||
<AlertDialogContent>
|
onConfirm={handleDelete}
|
||||||
<AlertDialogHeader>
|
isWorking={data.isWorking}
|
||||||
<AlertDialogTitle>Delete class</AlertDialogTitle>
|
/>
|
||||||
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/modules/classes/components/class-delete-dialog.tsx
Normal file
34
src/modules/classes/components/class-delete-dialog.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import type { AdminClassListItem } from "../types"
|
||||||
|
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||||
|
|
||||||
|
export function ClassDeleteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
item,
|
||||||
|
onConfirm,
|
||||||
|
isWorking,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
item: AdminClassListItem | null
|
||||||
|
onConfirm: () => Promise<void>
|
||||||
|
isWorking: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
return (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("delete.title")}
|
||||||
|
description={t("delete.description", { name: item?.name || t("delete.thisClass") })}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
isWorking={isWorking}
|
||||||
|
confirmText={t("delete.confirm")}
|
||||||
|
cancelText={t("delete.cancel")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronRight, FileText } from "lucide-react"
|
import { ChevronRight, FileText } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -26,20 +28,21 @@ interface ClassAssignmentsWidgetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
|
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
const activeAssignments = assignments.filter((a) => a.isActive)
|
const activeAssignments = assignments.filter((a) => a.isActive)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-fit">
|
<Card className="h-fit">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("detail.widgets.recentHomework")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{activeAssignments.length} active assignments
|
{t("detail.assignments.activeCount", { count: activeAssignments.length })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
|
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
|
||||||
View All
|
{t("detail.assignments.viewAll")}
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -51,14 +54,14 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
|
|||||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">No homework yet</p>
|
<p className="text-sm font-medium">{t("detail.empty.noAssignments")}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Create an assignment to get started.
|
{t("detail.empty.noAssignmentsDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" asChild>
|
||||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||||
Create Homework
|
{t("detail.assignments.createHomework")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,11 +81,16 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
|
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
|
||||||
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
|
{assignment.dueAt
|
||||||
|
? t("detail.assignments.due", { date: formatDate(assignment.dueAt) })
|
||||||
|
: t("detail.assignments.noDueDate")}
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
{assignment.submittedCount}/{assignment.targetCount} Submitted
|
{t("detail.assignments.submittedCount", {
|
||||||
|
submitted: assignment.submittedCount,
|
||||||
|
total: assignment.targetCount,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +103,7 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
|
|||||||
</Badge>
|
</Badge>
|
||||||
{typeof assignment.avgScore === "number" && (
|
{typeof assignment.avgScore === "number" && (
|
||||||
<span className="text-xs font-medium tabular-nums">
|
<span className="text-xs font-medium tabular-nums">
|
||||||
Avg: {assignment.avgScore.toFixed(0)}%
|
{t("detail.assignments.avgLabel")}: {assignment.avgScore.toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
|
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -34,6 +35,7 @@ export function ClassHeader({
|
|||||||
studentCount,
|
studentCount,
|
||||||
}: ClassHeaderProps) {
|
}: ClassHeaderProps) {
|
||||||
const [showEdit, setShowEdit] = useState(false)
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,45 +58,45 @@ export function ClassHeader({
|
|||||||
{homeroom && (
|
{homeroom && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/40">•</span>
|
<span className="text-muted-foreground/40">•</span>
|
||||||
<span>Homeroom {homeroom}</span>
|
<span>{t("detail.header.homeroom", { name: homeroom })}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{room && (
|
{room && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/40">•</span>
|
<span className="text-muted-foreground/40">•</span>
|
||||||
<span>Room {room}</span>
|
<span>{t("detail.header.room", { room })}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground/40">•</span>
|
<span className="text-muted-foreground/40">•</span>
|
||||||
<span>{studentCount} Students</span>
|
<span>{t("detail.header.students", { count: studentCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" className="hidden sm:flex">
|
<Button variant="outline" size="sm" className="hidden sm:flex">
|
||||||
<Share2 className="mr-2 h-4 w-4" />
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
Invite
|
{t("detail.header.invite")}
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<span className="sr-only">More actions</span>
|
<span className="sr-only">{t("detail.header.moreActions")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit details
|
{t("detail.header.editDetails")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Share2 className="mr-2 h-4 w-4" />
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
Invite students
|
{t("detail.header.inviteStudents")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Class settings
|
{t("detail.header.classSettings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
|
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
|
|
||||||
@@ -16,30 +18,31 @@ export function ClassOverviewStats({
|
|||||||
papersToGrade,
|
papersToGrade,
|
||||||
overdueCount,
|
overdueCount,
|
||||||
}: ClassOverviewStatsProps) {
|
}: ClassOverviewStatsProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Class Average"
|
title={t("detail.overview.averageScore")}
|
||||||
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
|
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
|
||||||
description="Overall performance"
|
description={t("detail.overview.overallPerformance")}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Submission Rate"
|
title={t("detail.overview.submissionRate")}
|
||||||
value={`${submissionRate.toFixed(0)}%`}
|
value={`${submissionRate.toFixed(0)}%`}
|
||||||
description="Average turn-in rate"
|
description={t("detail.overview.averageTurnInRate")}
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle2}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="To Grade"
|
title={t("detail.overview.papersToGrade")}
|
||||||
value={papersToGrade.toString()}
|
value={papersToGrade.toString()}
|
||||||
description="Pending reviews"
|
description={t("detail.overview.pendingReviews")}
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Missed Deadlines"
|
title={t("detail.overview.overdueCount")}
|
||||||
value={overdueCount.toString()}
|
value={overdueCount.toString()}
|
||||||
description="Active assignments past due"
|
description={t("detail.overview.activeAssignmentsPastDue")}
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
|
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
@@ -10,31 +12,32 @@ interface ClassQuickActionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
|
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("detail.widgets.quickActions")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2">
|
<CardContent className="grid gap-2">
|
||||||
<Button asChild className="w-full justify-start" size="sm">
|
<Button asChild className="w-full justify-start" size="sm">
|
||||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||||
<FilePlus className="mr-2 h-4 w-4" />
|
<FilePlus className="mr-2 h-4 w-4" />
|
||||||
Create Homework
|
{t("detail.assignments.createHomework")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="w-full justify-start" size="sm">
|
<Button asChild variant="outline" className="w-full justify-start" size="sm">
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
Manage Schedule
|
{t("detail.quickActions.manageSchedule")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
Message Class (Coming soon)
|
{t("detail.quickActions.messageClassComingSoon")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
Class Settings
|
{t("detail.header.classSettings")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
|
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
@@ -12,17 +14,28 @@ interface ClassScheduleWidgetProps {
|
|||||||
schedule: ClassScheduleItem[]
|
schedule: ClassScheduleItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
||||||
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
|
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
|
||||||
|
|
||||||
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
|
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const weekdayLabels = [
|
||||||
|
t("schedule.weekday.1"),
|
||||||
|
t("schedule.weekday.2"),
|
||||||
|
t("schedule.weekday.3"),
|
||||||
|
t("schedule.weekday.4"),
|
||||||
|
t("schedule.weekday.5"),
|
||||||
|
]
|
||||||
// Group by weekday
|
// Group by weekday
|
||||||
const groupedSchedule = schedule.reduce((acc, item) => {
|
// P2-A: 使用 reduce<T> 泛型参数替代 `{} as Record<...>` 反模式
|
||||||
const day = item.weekday
|
const groupedSchedule = schedule.reduce<Record<number, ClassScheduleItem[]>>(
|
||||||
if (!acc[day]) acc[day] = []
|
(acc, item) => {
|
||||||
acc[day].push(item)
|
const day = item.weekday
|
||||||
return acc
|
if (!acc[day]) acc[day] = []
|
||||||
}, {} as Record<number, ClassScheduleItem[]>)
|
acc[day].push(item)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
// Sort items within each day by start time
|
// Sort items within each day by start time
|
||||||
Object.keys(groupedSchedule).forEach(key => {
|
Object.keys(groupedSchedule).forEach(key => {
|
||||||
@@ -35,15 +48,15 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
|
|||||||
<div className="rounded-full bg-muted p-3">
|
<div className="rounded-full bg-muted p-3">
|
||||||
<Calendar className="h-6 w-6 text-muted-foreground" />
|
<Calendar className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
|
<p className="text-sm text-muted-foreground">{t("detail.empty.noScheduleDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||||
{WEEKDAYS.slice(0, 5).map((day) => (
|
{weekdayLabels.map((day, idx) => (
|
||||||
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
<div key={WEEKDAY_INDICES[idx]} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -89,13 +102,14 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
|
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
return (
|
return (
|
||||||
<Card className="h-fit">
|
<Card className="h-fit">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("detail.widgets.weeklySchedule")}</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||||
Manage
|
{t("detail.schedule.manage")}
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -103,7 +117,7 @@ export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetPr
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<ClassScheduleGrid schedule={schedule} />
|
<ClassScheduleGrid schedule={schedule} />
|
||||||
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
||||||
* Showing Mon-Fri schedule
|
* {t("detail.schedule.showingWeekdays")}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronRight, Users } from "lucide-react"
|
import { ChevronRight, Users } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -22,20 +24,21 @@ interface ClassStudentsWidgetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
|
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
const activeCount = students.filter(s => s.status === "active").length
|
const activeCount = students.filter(s => s.status === "active").length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-fit">
|
<Card className="h-fit">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base font-semibold">Students</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("detail.widgets.studentList")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{activeCount} active students
|
{t("detail.students.activeCount", { count: activeCount })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
|
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
|
||||||
View All
|
{t("detail.students.viewAll")}
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -46,7 +49,7 @@ export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetPr
|
|||||||
<div className="rounded-full bg-muted p-3">
|
<div className="rounded-full bg-muted p-3">
|
||||||
<Users className="h-6 w-6 text-muted-foreground" />
|
<Users className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
|
<p className="text-sm text-muted-foreground">{t("students.empty.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
import { ChevronDown } from "lucide-react"
|
import { ChevronDown } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
@@ -35,25 +36,6 @@ interface ClassTrendsWidgetProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
submitted: {
|
|
||||||
label: "Submitted",
|
|
||||||
color: "hsl(var(--primary))",
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
label: "Total Students",
|
|
||||||
color: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
avg: {
|
|
||||||
label: "Average Score",
|
|
||||||
color: "hsl(var(--chart-2))",
|
|
||||||
},
|
|
||||||
median: {
|
|
||||||
label: "Median Score",
|
|
||||||
color: "hsl(var(--chart-4))",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
|
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
|
||||||
const data = [...assignments].reverse().map(a => ({
|
const data = [...assignments].reverse().map(a => ({
|
||||||
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
|
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
|
||||||
@@ -78,6 +60,13 @@ export function ClassSubmissionTrendChart({
|
|||||||
data: Record<string, string | number>[]
|
data: Record<string, string | number>[]
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const chartConfig = {
|
||||||
|
submitted: { label: t("detail.trends.submitted"), color: "hsl(var(--primary))" },
|
||||||
|
target: { label: t("detail.trends.totalStudents"), color: "hsl(var(--muted-foreground))" },
|
||||||
|
avg: { label: t("detail.trends.averageScore"), color: "hsl(var(--chart-2))" },
|
||||||
|
median: { label: t("detail.trends.medianScore"), color: "hsl(var(--chart-4))" },
|
||||||
|
} satisfies ChartConfig
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className={className}>
|
<ChartContainer config={chartConfig} className={className}>
|
||||||
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||||
@@ -120,11 +109,21 @@ export function ClassSubmissionTrendChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
|
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const chartConfig = {
|
||||||
|
submitted: { label: t("detail.trends.submitted"), color: "hsl(var(--primary))" },
|
||||||
|
target: { label: t("detail.trends.totalStudents"), color: "hsl(var(--muted-foreground))" },
|
||||||
|
avg: { label: t("detail.trends.averageScore"), color: "hsl(var(--chart-2))" },
|
||||||
|
median: { label: t("detail.trends.medianScore"), color: "hsl(var(--chart-4))" },
|
||||||
|
} satisfies ChartConfig
|
||||||
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
||||||
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
||||||
|
|
||||||
// Extract unique subjects
|
// Extract unique subjects
|
||||||
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
|
// P2-A: 使用类型守卫替代 `as string[]`(filter(Boolean) 不会收窄类型是 TS 已知陷阱)
|
||||||
|
const subjects = Array.from(new Set(
|
||||||
|
assignments.map((a) => a.subject).filter((s): s is string => typeof s === "string")
|
||||||
|
))
|
||||||
|
|
||||||
const activeAssignments = assignments.filter((a) => {
|
const activeAssignments = assignments.filter((a) => {
|
||||||
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
|
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
|
||||||
@@ -140,7 +139,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
|||||||
const lastAssignment = chartData[chartData.length - 1]
|
const lastAssignment = chartData[chartData.length - 1]
|
||||||
|
|
||||||
let metricValue = "0%"
|
let metricValue = "0%"
|
||||||
const metricLabel = "Latest"
|
const metricLabel = t("detail.trends.latest")
|
||||||
|
|
||||||
if (lastAssignment) {
|
if (lastAssignment) {
|
||||||
if (chartTab === "submission") {
|
if (chartTab === "submission") {
|
||||||
@@ -159,16 +158,16 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
|
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
|
||||||
{chartTab === "submission" ? "Submission" : "Score"}
|
{chartTab === "submission" ? t("detail.trends.submission") : t("detail.trends.score")}
|
||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
|
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
|
||||||
Submission Trends
|
{t("detail.widgets.submissionTrends")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
|
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
|
||||||
Score Trends
|
{t("detail.trends.scoreTrends")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -177,13 +176,13 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
|
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||||
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
|
{selectedSubject === "all" ? t("detail.trends.allSubjects") : selectedSubject}
|
||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
|
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
|
||||||
All Subjects
|
{t("detail.trends.allSubjects")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{subjects.map(s => (
|
{subjects.map(s => (
|
||||||
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
|
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
|
||||||
@@ -275,16 +274,16 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base font-semibold">
|
<CardTitle className="text-base font-semibold">
|
||||||
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
|
{chartTab === "submission" ? t("detail.widgets.submissionTrends") : t("detail.trends.scoreTrends")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
|
{chartTab === "submission" ? t("detail.trends.recentTurnInRates") : t("detail.trends.avgVsMedian")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
|
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
|
||||||
<TabsList className="grid w-full grid-cols-2 h-8">
|
<TabsList className="grid w-full grid-cols-2 h-8">
|
||||||
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
|
<TabsTrigger value="submission" className="text-xs">{t("detail.trends.submission")}</TabsTrigger>
|
||||||
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
|
<TabsTrigger value="score" className="text-xs">{t("detail.trends.score")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +295,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
|||||||
value="all"
|
value="all"
|
||||||
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
||||||
>
|
>
|
||||||
All Subjects
|
{t("detail.trends.allSubjects")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{subjects.map(s => (
|
{subjects.map(s => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -387,7 +386,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
|
||||||
No data for this subject
|
{t("detail.trends.noDataForSubject")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -36,10 +37,11 @@ export function EditClassDialog({
|
|||||||
classId,
|
classId,
|
||||||
initialData,
|
initialData,
|
||||||
}: EditClassDialogProps) {
|
}: EditClassDialogProps) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
const handleEdit = async (formData: FormData) => {
|
const handleEdit = async (formData: FormData): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await updateTeacherClassAction(classId, null, formData)
|
const res = await updateTeacherClassAction(classId, null, formData)
|
||||||
@@ -48,10 +50,10 @@ export function EditClassDialog({
|
|||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to update class")
|
toast.error(res.message || t("list.failedUpdate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update class")
|
toast.error(t("list.failedUpdate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
@@ -67,26 +69,26 @@ export function EditClassDialog({
|
|||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit class</DialogTitle>
|
<DialogTitle>{t("detail.edit.title")}</DialogTitle>
|
||||||
<DialogDescription>Update basic class information.</DialogDescription>
|
<DialogDescription>{t("detail.edit.title")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleEdit}>
|
<form action={handleEdit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="schoolName" className="text-right">
|
<Label htmlFor="schoolName" className="text-right">
|
||||||
School
|
{t("list.column.school")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="schoolName"
|
id="schoolName"
|
||||||
name="schoolName"
|
name="schoolName"
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
defaultValue={initialData.schoolName ?? ""}
|
defaultValue={initialData.schoolName ?? ""}
|
||||||
placeholder="Optional"
|
placeholder={t("form.optional")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="name" className="text-right">
|
<Label htmlFor="name" className="text-right">
|
||||||
Name
|
{t("detail.edit.name")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@@ -98,7 +100,7 @@ export function EditClassDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="grade" className="text-right">
|
<Label htmlFor="grade" className="text-right">
|
||||||
Grade
|
{t("class.grade")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="grade"
|
id="grade"
|
||||||
@@ -110,7 +112,7 @@ export function EditClassDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="homeroom" className="text-right">
|
<Label htmlFor="homeroom" className="text-right">
|
||||||
Homeroom
|
{t("detail.edit.homeroom")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="homeroom"
|
id="homeroom"
|
||||||
@@ -121,7 +123,7 @@ export function EditClassDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="room" className="text-right">
|
<Label htmlFor="room" className="text-right">
|
||||||
Room
|
{t("detail.edit.room")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="room"
|
id="room"
|
||||||
@@ -133,7 +135,7 @@ export function EditClassDialog({
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
{isWorking ? "Saving..." : "Save Changes"}
|
{isWorking ? t("form.saving") : t("form.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
57
src/modules/classes/components/class-error-boundary.tsx
Normal file
57
src/modules/classes/components/class-error-boundary.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级模块 Error Boundary。
|
||||||
|
*
|
||||||
|
* 薄包装:委托给共享 SectionErrorBoundary,通过自定义 fallback 实现
|
||||||
|
* 重试时调用 router.refresh() 刷新服务端数据。
|
||||||
|
* 保留同名导出以兼容现有 import。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
|
interface ClassErrorBoundaryProps {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassErrorBoundary({
|
||||||
|
children,
|
||||||
|
fallback,
|
||||||
|
}: ClassErrorBoundaryProps): ReactNode {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const customFallback = (_error: Error, reset: () => void): ReactNode => {
|
||||||
|
const handleRetry = (): void => {
|
||||||
|
reset()
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
|
||||||
|
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
{t("errors.boundary.description")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleRetry}>{t("errors.boundary.retry")}</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
|
||||||
|
{children}
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
232
src/modules/classes/components/class-form-dialog.tsx
Normal file
232
src/modules/classes/components/class-form-dialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import type { AdminClassListItem, TeacherOption } from "../types"
|
||||||
|
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||||
|
import type { ClassFormGrade, SubjectTeacherState } from "./class-form-utils"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
|
|
||||||
|
export function ClassFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
mode,
|
||||||
|
editItem,
|
||||||
|
teachers,
|
||||||
|
schools,
|
||||||
|
grades,
|
||||||
|
onSubmit,
|
||||||
|
isWorking,
|
||||||
|
teacherId,
|
||||||
|
schoolId,
|
||||||
|
gradeId,
|
||||||
|
subjectTeachers,
|
||||||
|
onTeacherIdChange,
|
||||||
|
onSchoolIdChange,
|
||||||
|
onGradeIdChange,
|
||||||
|
onSubjectTeacherChange,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
mode: "create" | "edit"
|
||||||
|
editItem?: AdminClassListItem | null
|
||||||
|
teachers: TeacherOption[]
|
||||||
|
schools?: { id: string; name: string }[]
|
||||||
|
grades: ClassFormGrade[]
|
||||||
|
onSubmit: (formData: FormData) => Promise<void>
|
||||||
|
isWorking: boolean
|
||||||
|
teacherId: string
|
||||||
|
schoolId: string
|
||||||
|
gradeId: string
|
||||||
|
subjectTeachers?: SubjectTeacherState[]
|
||||||
|
onTeacherIdChange: (id: string) => void
|
||||||
|
onSchoolIdChange: (id: string) => void
|
||||||
|
onGradeIdChange: (id: string) => void
|
||||||
|
onSubjectTeacherChange?: (subject: string, teacherId: string | null) => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const isEdit = mode === "edit"
|
||||||
|
const selectedSchool = schools?.find((s) => s.id === schoolId)
|
||||||
|
const selectedGrade = grades.find((g) => g.id === gradeId)
|
||||||
|
// admin 模式:学校来自学校选择;grade 模式:学校来自所选年级
|
||||||
|
const hiddenSchoolId = schools ? schoolId : (selectedGrade?.schoolId ?? "")
|
||||||
|
const hiddenSchoolName = schools ? (selectedSchool?.name ?? "") : (selectedGrade?.schoolName ?? "")
|
||||||
|
const showForm = mode === "create" || Boolean(editItem)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (isWorking) return
|
||||||
|
onOpenChange(o)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? t("form.editTitle") : t("form.createTitle")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{showForm ? (
|
||||||
|
<form action={onSubmit} className="space-y-4">
|
||||||
|
{schools ? (
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{t("form.school")}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select
|
||||||
|
value={schoolId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
onSchoolIdChange(v)
|
||||||
|
onGradeIdChange("")
|
||||||
|
}}
|
||||||
|
disabled={schools.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={schools.length === 0 ? t("form.noSchools") : t("form.selectSchool")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{schools.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{t("form.grade")}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={gradeId} onValueChange={onGradeIdChange} disabled={grades.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={grades.length === 0 ? t("form.noGrades") : t("form.selectGrade")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{grades.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{schools ? g.name : `${g.name} (${g.schoolName ?? ""})`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="gradeId" value={gradeId} />
|
||||||
|
<input type="hidden" name="grade" value={selectedGrade?.name ?? ""} />
|
||||||
|
<input type="hidden" name="schoolId" value={hiddenSchoolId} />
|
||||||
|
<input type="hidden" name="schoolName" value={hiddenSchoolName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="class-name" className="text-right">
|
||||||
|
{t("form.name")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="class-name"
|
||||||
|
name="name"
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={t("form.namePlaceholder")}
|
||||||
|
defaultValue={isEdit ? editItem?.name : undefined}
|
||||||
|
autoFocus={!isEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="class-homeroom" className="text-right">
|
||||||
|
{t("form.homeroomLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="class-homeroom"
|
||||||
|
name="homeroom"
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={t("form.optional")}
|
||||||
|
defaultValue={isEdit ? editItem?.homeroom ?? "" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="class-room" className="text-right">
|
||||||
|
{t("form.room")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="class-room"
|
||||||
|
name="room"
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={t("form.optional")}
|
||||||
|
defaultValue={isEdit ? editItem?.room ?? "" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{t("form.homeroom")}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={teacherId} onValueChange={onTeacherIdChange} disabled={teachers.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={teachers.length === 0 ? t("form.noTeachers") : t("form.selectTeacher")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((teacher) => (
|
||||||
|
<SelectItem key={teacher.id} value={teacher.id}>
|
||||||
|
{teacher.name} ({teacher.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="teacherId" value={teacherId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdit && subjectTeachers && onSubjectTeacherChange ? (
|
||||||
|
<div className="space-y-3 rounded-md border p-4">
|
||||||
|
<div className="text-sm font-medium">{t("form.subjectTeachers")}</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
||||||
|
const selected = subjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
||||||
|
return (
|
||||||
|
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{subject}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select
|
||||||
|
value={selected ?? ""}
|
||||||
|
onValueChange={(v) => onSubjectTeacherChange(subject, v ? v : null)}
|
||||||
|
disabled={teachers.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={teachers.length === 0 ? t("form.noTeachers") : t("form.selectTeacher")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((teacher) => (
|
||||||
|
<SelectItem key={teacher.id} value={teacher.id}>
|
||||||
|
{teacher.name} ({teacher.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="subjectTeachers" value={JSON.stringify(subjectTeachers)} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking || teachers.length === 0 || !teacherId || !gradeId}>
|
||||||
|
{isEdit ? t("form.save") : t("form.create")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/modules/classes/components/class-form-utils.ts
Normal file
35
src/modules/classes/components/class-form-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ClassSubjectTeacherAssignment } from "../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单使用的年级选项(统一 admin / grade 两种来源的年级形状)。
|
||||||
|
* - admin 模式:由 `{ id, name, school: { id, name } }` 转换而来
|
||||||
|
* - grade 模式:managedGrades 已具备 `{ id, name, schoolId, schoolName }`
|
||||||
|
*/
|
||||||
|
export type ClassFormGrade = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
schoolId?: string
|
||||||
|
schoolName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑弹窗中科目-教师分配的表单状态。
|
||||||
|
*/
|
||||||
|
export type SubjectTeacherState = {
|
||||||
|
subject: string
|
||||||
|
teacherId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ClassSubjectTeacherAssignment }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化班级任课教师列表为可读字符串。
|
||||||
|
* 统一使用中文标点(冒号无空格、中文逗号分隔),与 K12 中文界面保持一致。
|
||||||
|
*/
|
||||||
|
export function formatSubjectTeachers(list: ClassSubjectTeacherAssignment[]): string {
|
||||||
|
const pairs = list
|
||||||
|
.filter((x) => x.teacher)
|
||||||
|
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
|
||||||
|
.filter((x) => x.length > 0)
|
||||||
|
return pairs.length > 0 ? pairs.join(",") : "-"
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export function ClassInvitationManager({
|
|||||||
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
|
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||||
|
|
||||||
const handleCopy = async (code: string) => {
|
const handleCopy = async (code: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code)
|
await navigator.clipboard.writeText(code)
|
||||||
toast.success(t("copied"))
|
toast.success(t("copied"))
|
||||||
@@ -78,7 +78,7 @@ export function ClassInvitationManager({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRevoke = async () => {
|
const handleRevoke = async (): Promise<void> => {
|
||||||
if (!revokeTarget) return
|
if (!revokeTarget) return
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +231,7 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
|
|||||||
const [note, setNote] = React.useState("")
|
const [note, setNote] = React.useState("")
|
||||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
106
src/modules/classes/components/class-list-table.tsx
Normal file
106
src/modules/classes/components/class-list-table.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import type { AdminClassListItem } from "../types"
|
||||||
|
import { formatSubjectTeachers } from "./class-form-utils"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
export function ClassListTable({
|
||||||
|
classes,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isWorking,
|
||||||
|
emptyDescription,
|
||||||
|
}: {
|
||||||
|
classes: AdminClassListItem[]
|
||||||
|
onEdit: (item: AdminClassListItem) => void
|
||||||
|
onDelete: (item: AdminClassListItem) => void
|
||||||
|
isWorking: boolean
|
||||||
|
emptyDescription?: string
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">{t("list.title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{classes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t("list.empty.title")}
|
||||||
|
description={emptyDescription ?? t("list.empty.description")}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("list.column.school")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.name")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.grade")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.homeroomLabel")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.room")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.homeroom")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.subjectTeachers")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("list.column.studentCount")}</TableHead>
|
||||||
|
<TableHead>{t("list.column.updated")}</TableHead>
|
||||||
|
<TableHead className="w-[60px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(c)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
{t("list.actions.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => onDelete(c)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("list.actions.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/modules/classes/components/class-list-toolbar.tsx
Normal file
32
src/modules/classes/components/class-list-toolbar.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
|
export function ClassListToolbar({
|
||||||
|
count,
|
||||||
|
onNew,
|
||||||
|
isWorking,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
count: number
|
||||||
|
onNew: () => void
|
||||||
|
isWorking: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
<Button onClick={onNew} disabled={isWorking || disabled}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("list.new")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/modules/classes/components/class-skeleton.tsx
Normal file
120
src/modules/classes/components/class-skeleton.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
|
|
||||||
|
interface ClassListSkeletonProps {
|
||||||
|
rows?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassListSkeleton({ rows = 5 }: ClassListSkeletonProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-5 w-10 rounded-full" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[60px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassCardSkeleton(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<ClassCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudentListSkeleton({ rows = 8 }: { rows?: number }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<div className="p-4">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-4 pt-0">
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: count }).map((_, idx) => (
|
||||||
|
<div key={idx} className="rounded-lg border bg-card p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<Skeleton className="h-4 w-[70%]" />
|
||||||
|
<Skeleton className="h-4 w-[85%]" />
|
||||||
|
<Skeleton className="h-4 w-[60%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,31 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo } from "react"
|
||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
|
import type { AdminClassListItem, TeacherOption } from "../types"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
|
||||||
import { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } from "../actions"
|
import { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } from "../actions"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { useClassData } from "../hooks/use-class-data"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { ClassDeleteDialog } from "./class-delete-dialog"
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
import { ClassFormDialog } from "./class-form-dialog"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { ClassListTable } from "./class-list-table"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { ClassListToolbar } from "./class-list-toolbar"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
|
||||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
export function GradeClassesClient({
|
export function GradeClassesClient({
|
||||||
classes,
|
classes,
|
||||||
@@ -36,401 +22,130 @@ export function GradeClassesClient({
|
|||||||
teachers: TeacherOption[]
|
teachers: TeacherOption[]
|
||||||
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
|
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("classes")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
|
||||||
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
|
||||||
|
|
||||||
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
||||||
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
const defaultGradeId = useMemo(() => managedGrades[0]?.id ?? "", [managedGrades])
|
||||||
const [createGradeId, setCreateGradeId] = useState(managedGrades[0]?.id ?? "")
|
const data = useClassData({ defaultTeacherId, defaultGradeId })
|
||||||
|
|
||||||
const [editTeacherId, setEditTeacherId] = useState("")
|
const handleCreate = async (formData: FormData): Promise<void> => {
|
||||||
const [editGradeId, setEditGradeId] = useState("")
|
data.setIsWorking(true)
|
||||||
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!createOpen) return
|
|
||||||
setCreateTeacherId(defaultTeacherId)
|
|
||||||
setCreateGradeId(managedGrades[0]?.id ?? "")
|
|
||||||
}, [createOpen, defaultTeacherId, managedGrades])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editItem) return
|
|
||||||
setEditTeacherId(editItem.teacher.id)
|
|
||||||
setEditGradeId(editItem.gradeId ?? managedGrades[0]?.id ?? "")
|
|
||||||
setEditSubjectTeachers(
|
|
||||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
|
||||||
subject: s,
|
|
||||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}, [editItem, managedGrades])
|
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
|
||||||
setIsWorking(true)
|
|
||||||
try {
|
try {
|
||||||
const res = await createGradeClassAction(undefined, formData)
|
const res = await createGradeClassAction(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setCreateOpen(false)
|
data.setCreateOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to create class")
|
toast.error(res.message || t("list.failedCreate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create class")
|
toast.error(t("list.failedCreate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
data.setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (formData: FormData) => {
|
const handleUpdate = async (formData: FormData): Promise<void> => {
|
||||||
if (!editItem) return
|
if (!data.editItem) return
|
||||||
setIsWorking(true)
|
data.setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await updateGradeClassAction(editItem.id, undefined, formData)
|
const res = await updateGradeClassAction(data.editItem.id, undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setEditItem(null)
|
data.setEditItem(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to update class")
|
toast.error(res.message || t("list.failedUpdate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update class")
|
toast.error(t("list.failedUpdate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
data.setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (): Promise<void> => {
|
||||||
if (!deleteItem) return
|
if (!data.deleteItem) return
|
||||||
setIsWorking(true)
|
data.setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await deleteGradeClassAction(deleteItem.id)
|
const res = await deleteGradeClassAction(data.deleteItem.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
setDeleteItem(null)
|
data.setDeleteItem(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to delete class")
|
toast.error(res.message || t("list.failedDelete"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to delete class")
|
toast.error(t("list.failedDelete"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
data.setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
|
||||||
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
|
|
||||||
const pairs = list
|
|
||||||
.filter((x) => x.teacher)
|
|
||||||
.map((x) => `${x.subject}: ${x.teacher?.name ?? ""}`)
|
|
||||||
.filter((x) => x.length > 0)
|
|
||||||
return pairs.length > 0 ? pairs.join(", ") : "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
|
|
||||||
const selectedEditGrade = managedGrades.find(g => g.id === editGradeId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end">
|
<ClassListToolbar
|
||||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking || managedGrades.length === 0}>
|
count={classes.length}
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
onNew={() => data.setCreateOpen(true)}
|
||||||
New class
|
isWorking={data.isWorking}
|
||||||
</Button>
|
disabled={managedGrades.length === 0}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Card className="shadow-none">
|
<ClassListTable
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
classes={classes}
|
||||||
<CardTitle className="text-base">All classes</CardTitle>
|
onEdit={data.setEditItem}
|
||||||
<Badge variant="secondary" className="tabular-nums">
|
onDelete={data.setDeleteItem}
|
||||||
{classes.length}
|
isWorking={data.isWorking}
|
||||||
</Badge>
|
emptyDescription={managedGrades.length === 0 ? t("grade.noManagedGradesDescription") : undefined}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
|
||||||
{classes.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="No classes"
|
|
||||||
description={managedGrades.length === 0 ? "You are not managing any grades yet." : "Create classes to manage students and schedules."}
|
|
||||||
className="h-auto border-none shadow-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>School</TableHead>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Grade</TableHead>
|
|
||||||
<TableHead>Homeroom</TableHead>
|
|
||||||
<TableHead>Room</TableHead>
|
|
||||||
<TableHead>Homeroom Teacher</TableHead>
|
|
||||||
<TableHead>Subject Teachers</TableHead>
|
|
||||||
<TableHead className="text-right">Students</TableHead>
|
|
||||||
<TableHead>Updated</TableHead>
|
|
||||||
<TableHead className="w-[60px]" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<TableRow key={c.id}>
|
|
||||||
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
|
||||||
<TableCell className="font-medium">{c.name}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setEditItem(c)}>
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => setDeleteItem(c)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<ClassFormDialog
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
open={data.createOpen}
|
||||||
<DialogHeader>
|
onOpenChange={data.setCreateOpen}
|
||||||
<DialogTitle>New class</DialogTitle>
|
mode="create"
|
||||||
</DialogHeader>
|
teachers={teachers}
|
||||||
<form action={handleCreate} className="space-y-4">
|
grades={managedGrades}
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
onSubmit={handleCreate}
|
||||||
<Label className="text-right">Grade</Label>
|
isWorking={data.isWorking}
|
||||||
<div className="col-span-3">
|
teacherId={data.createTeacherId}
|
||||||
<Select value={createGradeId} onValueChange={setCreateGradeId} disabled={managedGrades.length === 0}>
|
schoolId={data.createSchoolId}
|
||||||
<SelectTrigger>
|
gradeId={data.createGradeId}
|
||||||
<SelectValue placeholder={managedGrades.length === 0 ? "No managed grades" : "Select a grade"} />
|
onTeacherIdChange={data.setCreateTeacherId}
|
||||||
</SelectTrigger>
|
onSchoolIdChange={data.setCreateSchoolId}
|
||||||
<SelectContent>
|
onGradeIdChange={data.setCreateGradeId}
|
||||||
{managedGrades.map((g) => (
|
/>
|
||||||
<SelectItem key={g.id} value={g.id}>
|
|
||||||
{g.name} ({g.schoolName})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="gradeId" value={createGradeId} />
|
|
||||||
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
|
|
||||||
<input type="hidden" name="schoolId" value={selectedCreateGrade?.schoolId ?? ""} />
|
|
||||||
<input type="hidden" name="schoolName" value={selectedCreateGrade?.schoolName ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<ClassFormDialog
|
||||||
<Label htmlFor="create-name" className="text-right">
|
open={Boolean(data.editItem)}
|
||||||
Name
|
onOpenChange={(o) => {
|
||||||
</Label>
|
if (!o) data.setEditItem(null)
|
||||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 3" autoFocus />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-homeroom" className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-room" className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Homeroom Teacher</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name} ({t.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="teacherId" value={createTeacherId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={Boolean(editItem)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (isWorking) return
|
|
||||||
if (!open) setEditItem(null)
|
|
||||||
}}
|
}}
|
||||||
>
|
mode="edit"
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
editItem={data.editItem}
|
||||||
<DialogHeader>
|
teachers={teachers}
|
||||||
<DialogTitle>Edit class</DialogTitle>
|
grades={managedGrades}
|
||||||
</DialogHeader>
|
onSubmit={handleUpdate}
|
||||||
{editItem ? (
|
isWorking={data.isWorking}
|
||||||
<form action={handleUpdate} className="space-y-4">
|
teacherId={data.editTeacherId}
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
schoolId={data.editSchoolId}
|
||||||
<Label className="text-right">Grade</Label>
|
gradeId={data.editGradeId}
|
||||||
<div className="col-span-3">
|
subjectTeachers={data.editSubjectTeachers}
|
||||||
<Select value={editGradeId} onValueChange={setEditGradeId} disabled={managedGrades.length === 0}>
|
onTeacherIdChange={data.setEditTeacherId}
|
||||||
<SelectTrigger>
|
onSchoolIdChange={data.setEditSchoolId}
|
||||||
<SelectValue placeholder="Select a grade" />
|
onGradeIdChange={data.setEditGradeId}
|
||||||
</SelectTrigger>
|
onSubjectTeacherChange={data.setSubjectTeacher}
|
||||||
<SelectContent>
|
/>
|
||||||
{managedGrades.map((g) => (
|
|
||||||
<SelectItem key={g.id} value={g.id}>
|
|
||||||
{g.name} ({g.schoolName})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="gradeId" value={editGradeId} />
|
|
||||||
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
|
|
||||||
<input type="hidden" name="schoolId" value={selectedEditGrade?.schoolId ?? ""} />
|
|
||||||
<input type="hidden" name="schoolName" value={selectedEditGrade?.schoolName ?? ""} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<ClassDeleteDialog
|
||||||
<Label htmlFor="edit-name" className="text-right">
|
open={Boolean(data.deleteItem)}
|
||||||
Name
|
onOpenChange={(o) => {
|
||||||
</Label>
|
if (!o) data.setDeleteItem(null)
|
||||||
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-homeroom" className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-room" className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Homeroom Teacher</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name} ({t.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="teacherId" value={editTeacherId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 rounded-md border p-4">
|
|
||||||
<div className="text-sm font-medium">Subject Teachers</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
|
||||||
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
|
||||||
return (
|
|
||||||
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">{subject}</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select
|
|
||||||
value={selected ?? ""}
|
|
||||||
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
|
|
||||||
disabled={teachers.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name} ({t.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isWorking || !editTeacherId}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={Boolean(deleteItem)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setDeleteItem(null)
|
|
||||||
}}
|
}}
|
||||||
title="Delete class"
|
item={data.deleteItem}
|
||||||
description={`This will permanently delete ${deleteItem?.name || "this class"}.`}
|
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
isWorking={isWorking}
|
isWorking={data.isWorking}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -54,24 +55,25 @@ export function MyClassesGrid({
|
|||||||
subjectOptions: string[]
|
subjectOptions: string[]
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
const [joinOpen, setJoinOpen] = useState(false)
|
const [joinOpen, setJoinOpen] = useState(false)
|
||||||
const [joinSubject, setJoinSubject] = useState("")
|
const [joinSubject, setJoinSubject] = useState("")
|
||||||
|
|
||||||
const handleJoin = async (formData: FormData) => {
|
const handleJoin = async (formData: FormData): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await joinClassByInvitationCodeAction(null, formData)
|
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || "Joined class successfully")
|
toast.success(res.message || t("invitation.joinSuccess"))
|
||||||
setJoinOpen(false)
|
setJoinOpen(false)
|
||||||
setJoinSubject("")
|
setJoinSubject("")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to join class")
|
toast.error(res.message || t("invitation.joinFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to join class")
|
toast.error(t("invitation.joinFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
@@ -98,7 +100,7 @@ export function MyClassesGrid({
|
|||||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
|
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
|
||||||
<Plus className="size-3.5" strokeWidth={3} />
|
<Plus className="size-3.5" strokeWidth={3} />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold tracking-tight">Join New Class</span>
|
<span className="font-semibold tracking-tight">{t("myClasses.joinNew")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -111,10 +113,10 @@ export function MyClassesGrid({
|
|||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
Join a Class
|
{t("myClasses.joinTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground mt-1.5">
|
<DialogDescription className="text-muted-foreground mt-1.5">
|
||||||
Enter the 6-digit invitation code provided by your administrator.
|
{t("myClasses.joinDescription")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,14 +125,14 @@ export function MyClassesGrid({
|
|||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="join-code" className="text-sm font-medium">
|
<Label htmlFor="join-code" className="text-sm font-medium">
|
||||||
Invitation Code
|
{t("myClasses.invitationCode")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="join-code"
|
id="join-code"
|
||||||
name="code"
|
name="code"
|
||||||
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
|
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
|
||||||
placeholder="e.g. 123456"
|
placeholder={t("myClasses.invitationCodePlaceholder")}
|
||||||
required
|
required
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
pattern="\d{6}"
|
pattern="\d{6}"
|
||||||
@@ -142,16 +144,16 @@ export function MyClassesGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Ask your administrator for the code if you don't have one.
|
{t("myClasses.invitationCodeHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="join-subject" className="text-sm font-medium">
|
<Label htmlFor="join-subject" className="text-sm font-medium">
|
||||||
教学科目
|
{t("myClasses.subject")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
|
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
|
||||||
<SelectTrigger id="join-subject" className="h-12">
|
<SelectTrigger id="join-subject" className="h-12">
|
||||||
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
|
<SelectValue placeholder={subjectOptions.length === 0 ? t("myClasses.noSubjects") : t("myClasses.selectSubject")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{subjectOptions.map((subject) => (
|
{subjectOptions.map((subject) => (
|
||||||
@@ -166,10 +168,10 @@ export function MyClassesGrid({
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||||
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||||
Cancel
|
{t("myClasses.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
|
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
|
||||||
{isWorking ? "Joining..." : "Join Class"}
|
{isWorking ? t("myClasses.joining") : t("myClasses.join")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -182,10 +184,10 @@ export function MyClassesGrid({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{classes.length === 0 ? (
|
{classes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No classes yet"
|
title={t("myClasses.empty.title")}
|
||||||
description="Join a class to start managing students and schedules."
|
description={t("myClasses.empty.description")}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
action={{ label: t("myClasses.join"), onClick: () => setJoinOpen(true) }}
|
||||||
className="h-[360px] bg-card border-dashed"
|
className="h-[360px] bg-card border-dashed"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -211,49 +213,50 @@ function ClassTicket({
|
|||||||
onWorkingChange: (v: boolean) => void
|
onWorkingChange: (v: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
|
||||||
const handleEnsureCode = async () => {
|
const handleEnsureCode = async (): Promise<void> => {
|
||||||
onWorkingChange(true)
|
onWorkingChange(true)
|
||||||
try {
|
try {
|
||||||
const res = await ensureClassInvitationCodeAction(c.id)
|
const res = await ensureClassInvitationCodeAction(c.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || "Invitation code ready")
|
toast.success(res.message || t("invitation.generateSuccess"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to generate invitation code")
|
toast.error(res.message || t("invitation.generateFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to generate invitation code")
|
toast.error(t("invitation.generateFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
onWorkingChange(false)
|
onWorkingChange(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRegenerateCode = async () => {
|
const handleRegenerateCode = async (): Promise<void> => {
|
||||||
onWorkingChange(true)
|
onWorkingChange(true)
|
||||||
try {
|
try {
|
||||||
const res = await regenerateClassInvitationCodeAction(c.id)
|
const res = await regenerateClassInvitationCodeAction(c.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || "Invitation code updated")
|
toast.success(res.message || t("invitation.regenerateSuccess"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to regenerate invitation code")
|
toast.error(res.message || t("list.failedRegenerate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to regenerate invitation code")
|
toast.error(t("list.failedRegenerate"))
|
||||||
} finally {
|
} finally {
|
||||||
onWorkingChange(false)
|
onWorkingChange(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyCode = async () => {
|
const handleCopyCode = async (): Promise<void> => {
|
||||||
const code = c.invitationCode ?? ""
|
const code = c.invitationCode ?? ""
|
||||||
if (!code) return
|
if (!code) return
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code)
|
await navigator.clipboard.writeText(code)
|
||||||
toast.success("Copied invitation code")
|
toast.success(t("myClasses.codeCopied"))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to copy")
|
toast.error(t("list.failedCopy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,11 +311,11 @@ function ClassTicket({
|
|||||||
<div className="space-y-2 text-sm text-muted-foreground">
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="size-4 text-muted-foreground/70" />
|
<Users className="size-4 text-muted-foreground/70" />
|
||||||
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
|
<span className="font-medium text-foreground/80">{c.studentCount}</span> {t("myClasses.students")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="size-4 text-muted-foreground/70" />
|
<MapPin className="size-4 text-muted-foreground/70" />
|
||||||
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
|
<span className="font-medium text-foreground/80">{c.room || t("myClasses.noRoom")}</span>
|
||||||
</div>
|
</div>
|
||||||
{c.schoolName && (
|
{c.schoolName && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -331,7 +334,7 @@ function ClassTicket({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
|
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">{t("myClasses.entryPass")}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
||||||
@@ -352,16 +355,16 @@ function ClassTicket({
|
|||||||
|
|
||||||
{c.invitationCode ? (
|
{c.invitationCode ? (
|
||||||
<div className="flex gap-1 z-10">
|
<div className="flex gap-1 z-10">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title={t("myClasses.copyCode")}>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title={t("myClasses.regenerateCode")}>
|
||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
||||||
Generate
|
{t("invitation.generate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,14 +388,14 @@ function ClassTicket({
|
|||||||
{/* Left: Submission Trends */}
|
{/* Left: Submission Trends */}
|
||||||
<div className="flex-1 flex flex-col gap-4 min-w-0">
|
<div className="flex-1 flex flex-col gap-4 min-w-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
|
<h4 className="text-sm font-semibold text-foreground/80">{t("myClasses.submissionTrends")}</h4>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
||||||
isPositive
|
isPositive
|
||||||
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
||||||
: "text-red-600 bg-red-50 border-red-100"
|
: "text-red-600 bg-red-50 border-red-100"
|
||||||
)}>
|
)}>
|
||||||
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
|
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>{t("myClasses.vsLastWeek")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
164
src/modules/classes/components/schedule-create-dialog.tsx
Normal file
164
src/modules/classes/components/schedule-create-dialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
|
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||||||
|
import { createClassScheduleItemAction } from "../actions"
|
||||||
|
import { SCHEDULE_WEEKDAYS } from "./schedule-utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课表新增对话框(P1-13 从 schedule-view.tsx 拆分)。
|
||||||
|
*
|
||||||
|
* 自管理 isWorking / createClassId 状态,父组件仅需控制 open 与 weekday。
|
||||||
|
*/
|
||||||
|
export function ScheduleCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
classes,
|
||||||
|
defaultClassId,
|
||||||
|
weekday,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
classes: TeacherClass[]
|
||||||
|
defaultClassId: string
|
||||||
|
weekday: ClassScheduleItem["weekday"]
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [createClassId, setCreateClassId] = useState<string>(defaultClassId)
|
||||||
|
|
||||||
|
// 打开时重置到默认班级,避免上一次选择残留
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open)
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) setCreateClassId(defaultClassId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
formData.set("classId", createClassId || defaultClassId)
|
||||||
|
formData.set("weekday", String(weekday))
|
||||||
|
const res = await createClassScheduleItemAction(null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || t("list.failedCreate"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("list.failedCreate"))
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayLabel = SCHEDULE_WEEKDAYS.find((w) => w.key === weekday)?.label ?? "schedule.weekday.1"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (isWorking) return
|
||||||
|
onOpenChange(v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("schedule.form.createTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("schedule.form.createDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{t("filters.class")}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={createClassId} onValueChange={setCreateClassId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("filters.selectClass")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="classId" value={createClassId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{t("schedule.column.weekday")}</Label>
|
||||||
|
<Input value={t(weekdayLabel)} readOnly className="col-span-3" />
|
||||||
|
<input type="hidden" name="weekday" value={String(weekday)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-startTime" className="text-right">
|
||||||
|
{t("schedule.form.startLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-endTime" className="text-right">
|
||||||
|
{t("schedule.form.endLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-course" className="text-right">
|
||||||
|
{t("schedule.column.subject")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="create-course"
|
||||||
|
name="course"
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={t("schedule.form.subjectPlaceholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-location" className="text-right">
|
||||||
|
{t("schedule.column.location")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="create-location"
|
||||||
|
name="location"
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={t("schedule.form.locationPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isWorking || !createClassId}>
|
||||||
|
{isWorking ? t("form.creating") : t("form.create")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/modules/classes/components/schedule-delete-dialog.tsx
Normal file
85
src/modules/classes/components/schedule-delete-dialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import type { ClassScheduleItem } from "../types"
|
||||||
|
import { deleteClassScheduleItemAction } from "../actions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课表删除确认对话框(P1-13 从 schedule-view.tsx 拆分)。
|
||||||
|
*
|
||||||
|
* 自管理 isWorking 状态,父组件仅需传入 deleteItem 与 onClose 回调。
|
||||||
|
*/
|
||||||
|
export function ScheduleDeleteDialog({
|
||||||
|
deleteItem,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
deleteItem: ClassScheduleItem | null
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const handleConfirm = async (): Promise<void> => {
|
||||||
|
if (!deleteItem) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await deleteClassScheduleItemAction(deleteItem.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onClose()
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || t("list.failedDelete"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("list.failedDelete"))
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteItem}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (isWorking) return
|
||||||
|
if (!v) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("schedule.form.deleteTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("schedule.form.deleteDescription")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isWorking}>{t("form.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void handleConfirm()
|
||||||
|
}}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isWorking ? t("form.deleting") : t("list.actions.delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
src/modules/classes/components/schedule-edit-dialog.tsx
Normal file
194
src/modules/classes/components/schedule-edit-dialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
|
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||||||
|
import { updateClassScheduleItemAction } from "../actions"
|
||||||
|
import { SCHEDULE_WEEKDAYS } from "./schedule-utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课表编辑对话框(P1-13 从 schedule-view.tsx 拆分)。
|
||||||
|
*
|
||||||
|
* 自管理 isWorking / editClassId / editWeekday 状态,父组件仅需传入 editItem。
|
||||||
|
*/
|
||||||
|
export function ScheduleEditDialog({
|
||||||
|
editItem,
|
||||||
|
classes,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
editItem: ClassScheduleItem | null
|
||||||
|
classes: TeacherClass[]
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [editClassId, setEditClassId] = useState<string>("")
|
||||||
|
const [editWeekday, setEditWeekday] = useState<string>("1")
|
||||||
|
|
||||||
|
// editItem 变更时同步本地表单状态
|
||||||
|
const [prevItem, setPrevItem] = useState(editItem)
|
||||||
|
if (editItem !== prevItem) {
|
||||||
|
setPrevItem(editItem)
|
||||||
|
if (editItem) {
|
||||||
|
setEditClassId(editItem.classId)
|
||||||
|
setEditWeekday(String(editItem.weekday))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||||
|
if (!editItem) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
formData.set("classId", editClassId)
|
||||||
|
formData.set("weekday", editWeekday)
|
||||||
|
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onClose()
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || t("list.failedUpdate"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("list.failedUpdate"))
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={!!editItem}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (isWorking) return
|
||||||
|
if (!v) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("schedule.form.editTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("schedule.form.editDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{t("filters.class")}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={editClassId} onValueChange={setEditClassId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("filters.selectClass")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="classId" value={editClassId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-weekday" className="text-right">
|
||||||
|
{t("schedule.column.weekday")}
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("filters.selectWeekday")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULE_WEEKDAYS.map((w) => (
|
||||||
|
<SelectItem key={w.key} value={String(w.key)}>
|
||||||
|
{t(w.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="weekday" value={editWeekday} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-startTime" className="text-right">
|
||||||
|
{t("schedule.form.startLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-startTime"
|
||||||
|
name="startTime"
|
||||||
|
type="time"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.startTime}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-endTime" className="text-right">
|
||||||
|
{t("schedule.form.endLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-endTime"
|
||||||
|
name="endTime"
|
||||||
|
type="time"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.endTime}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-course" className="text-right">
|
||||||
|
{t("schedule.column.subject")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-course"
|
||||||
|
name="course"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.course}
|
||||||
|
placeholder={t("schedule.form.subjectPlaceholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-location" className="text-right">
|
||||||
|
{t("schedule.column.location")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-location"
|
||||||
|
name="location"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.location ?? ""}
|
||||||
|
placeholder={t("schedule.form.locationPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||||
|
{isWorking ? t("form.saving") : t("form.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -32,6 +33,7 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
const handleCreate = async (formData: FormData): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
formData.set("classId", createClassId)
|
formData.set("classId", createClassId)
|
||||||
@@ -58,27 +60,27 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to create schedule item")
|
toast.error(res.message || t("list.failedCreate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create schedule item")
|
toast.error(t("list.failedCreate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedClass = classes.find((c) => c.id === classId)
|
const selectedClass = classes.find((c) => c.id === classId)
|
||||||
const title = selectedClass ? selectedClass.name : "All Classes"
|
const title = selectedClass ? selectedClass.name : t("filters.allClasses")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center justify-between py-2">
|
<div className="relative flex items-center justify-between py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
||||||
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
|
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
|
||||||
<SelectValue placeholder="All Classes" />
|
<SelectValue placeholder={t("filters.allClasses")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
|
<SelectItem value="all" className="text-xs">{t("filters.allClasses")}</SelectItem>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||||
{c.name}
|
{c.name}
|
||||||
@@ -106,22 +108,22 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Add Event
|
{t("filters.addEvent")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add schedule item</DialogTitle>
|
<DialogTitle>{t("schedule.form.createTitle")}</DialogTitle>
|
||||||
<DialogDescription>Create a class schedule entry.</DialogDescription>
|
<DialogDescription>{t("filters.createScheduleEntryDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleCreate}>
|
<form action={handleCreate}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label className="text-right">Class</Label>
|
<Label className="text-right">{t("filters.class")}</Label>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<Select value={createClassId} onValueChange={setCreateClassId}>
|
<Select value={createClassId} onValueChange={setCreateClassId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a class" />
|
<SelectValue placeholder={t("filters.selectClass")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
@@ -136,21 +138,21 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="weekday" className="text-right">
|
<Label htmlFor="weekday" className="text-right">
|
||||||
Weekday
|
{t("schedule.column.weekday")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<Select value={weekday} onValueChange={setWeekday}>
|
<Select value={weekday} onValueChange={setWeekday}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select weekday" />
|
<SelectValue placeholder={t("filters.selectWeekday")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">Mon</SelectItem>
|
<SelectItem value="1">{t("schedule.weekday.1")}</SelectItem>
|
||||||
<SelectItem value="2">Tue</SelectItem>
|
<SelectItem value="2">{t("schedule.weekday.2")}</SelectItem>
|
||||||
<SelectItem value="3">Wed</SelectItem>
|
<SelectItem value="3">{t("schedule.weekday.3")}</SelectItem>
|
||||||
<SelectItem value="4">Thu</SelectItem>
|
<SelectItem value="4">{t("schedule.weekday.4")}</SelectItem>
|
||||||
<SelectItem value="5">Fri</SelectItem>
|
<SelectItem value="5">{t("schedule.weekday.5")}</SelectItem>
|
||||||
<SelectItem value="6">Sat</SelectItem>
|
<SelectItem value="6">{t("schedule.weekday.6")}</SelectItem>
|
||||||
<SelectItem value="7">Sun</SelectItem>
|
<SelectItem value="7">{t("schedule.weekday.7")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input type="hidden" name="weekday" value={weekday} />
|
<input type="hidden" name="weekday" value={weekday} />
|
||||||
@@ -159,35 +161,35 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="startTime" className="text-right">
|
<Label htmlFor="startTime" className="text-right">
|
||||||
Start
|
{t("filters.startTime")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
|
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="endTime" className="text-right">
|
<Label htmlFor="endTime" className="text-right">
|
||||||
End
|
{t("filters.endTime")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
|
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="course" className="text-right">
|
<Label htmlFor="course" className="text-right">
|
||||||
Course
|
{t("schedule.column.subject")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="course" name="course" className="col-span-3" placeholder="e.g. Math" required />
|
<Input id="course" name="course" className="col-span-3" placeholder={t("filters.subjectPlaceholderExample")} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="location" className="text-right">
|
<Label htmlFor="location" className="text-right">
|
||||||
Location
|
{t("schedule.column.location")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="location" name="location" className="col-span-3" placeholder="Optional" />
|
<Input id="location" name="location" className="col-span-3" placeholder={t("schedule.form.locationPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isWorking || !createClassId}>
|
<Button type="submit" disabled={isWorking || !createClassId}>
|
||||||
{isWorking ? "Creating..." : "Create"}
|
{isWorking ? t("form.creating") : t("form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
100
src/modules/classes/components/schedule-utils.ts
Normal file
100
src/modules/classes/components/schedule-utils.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 课表纯函数工具集(P0-4 审计修复 + P2-4 可测试性抽取)。
|
||||||
|
*
|
||||||
|
* 修复点:
|
||||||
|
* - 原 `getSubjectColor` 使用英文关键词('math'/'english')匹配科目,
|
||||||
|
* 但 `DEFAULT_CLASS_SUBJECTS` 为中文("数学"/"英语"),导致所有中文科目
|
||||||
|
* 落到 default 分支,颜色视觉分组完全失效。
|
||||||
|
* - 现支持中英文双语匹配,并导出为纯函数便于单测。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 科目到颜色类的映射表(中英文关键词均匹配)。 */
|
||||||
|
const SUBJECT_COLOR_MAP: ReadonlyArray<{ keywords: readonly string[]; classes: string }> = [
|
||||||
|
{
|
||||||
|
keywords: ["math", "数学"],
|
||||||
|
classes: "bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["physics", "物理", "science", "科学"],
|
||||||
|
classes: "bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["english", "英语", "lit"],
|
||||||
|
classes: "bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["history", "历史", "geo", "地理", "社会"],
|
||||||
|
classes: "bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["art", "美术", "music", "音乐"],
|
||||||
|
classes: "bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["sport", "pe", "体育"],
|
||||||
|
classes: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["chinese", "语文", "language"],
|
||||||
|
classes: "bg-rose-500/10 text-rose-700 border-rose-500/20 hover:bg-rose-500/20",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const DEFAULT_SUBJECT_COLOR = "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据科目名称返回对应的颜色类名(支持中英文)。
|
||||||
|
* 匹配规则:科目名转为小写后,检查是否包含映射表中的任一关键词。
|
||||||
|
*/
|
||||||
|
export function getSubjectColor(subject: string): string {
|
||||||
|
const s = subject.toLowerCase()
|
||||||
|
for (const entry of SUBJECT_COLOR_MAP) {
|
||||||
|
if (entry.keywords.some((kw) => s.includes(kw.toLowerCase()))) {
|
||||||
|
return entry.classes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_SUBJECT_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课表时间范围:8:00 - 18:00。 */
|
||||||
|
const MIN_TIME_MINUTES = 8 * 60
|
||||||
|
const MAX_TIME_MINUTES = 18 * 60
|
||||||
|
const TOTAL_DURATION_MINUTES = MAX_TIME_MINUTES - MIN_TIME_MINUTES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 "HH:MM" 时间字符串转换为当天从 0 点起的分钟数。
|
||||||
|
*/
|
||||||
|
export function timeToMinutes(time: string): number {
|
||||||
|
const parts = time.split(":").map(Number)
|
||||||
|
const hours = parts[0] ?? 0
|
||||||
|
const minutes = parts[1] ?? 0
|
||||||
|
return hours * 60 + minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据课程的开始/结束时间计算课表块在时间轴上的定位样式(top% / height%)。
|
||||||
|
* 时间轴范围:8:00 - 18:00。
|
||||||
|
*/
|
||||||
|
export function getPositionStyle(startTime: string, endTime: string): { top: string; height: string } {
|
||||||
|
const startMinutes = timeToMinutes(startTime)
|
||||||
|
const endMinutes = timeToMinutes(endTime)
|
||||||
|
|
||||||
|
const top = Math.max(0, ((startMinutes - MIN_TIME_MINUTES) / TOTAL_DURATION_MINUTES) * 100)
|
||||||
|
const height = Math.min(100 - top, ((endMinutes - startMinutes) / TOTAL_DURATION_MINUTES) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${top}%`,
|
||||||
|
height: `${height}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课表周常量(周一~周日),label 为 i18n 翻译键。 */
|
||||||
|
export const SCHEDULE_WEEKDAYS: ReadonlyArray<{ key: 1 | 2 | 3 | 4 | 5 | 6 | 7; label: string }> = [
|
||||||
|
{ key: 1, label: "schedule.weekday.1" },
|
||||||
|
{ key: 2, label: "schedule.weekday.2" },
|
||||||
|
{ key: 3, label: "schedule.weekday.3" },
|
||||||
|
{ key: 4, label: "schedule.weekday.4" },
|
||||||
|
{ key: 5, label: "schedule.weekday.5" },
|
||||||
|
{ key: 6, label: "schedule.weekday.6" },
|
||||||
|
{ key: 7, label: "schedule.weekday.7" },
|
||||||
|
]
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useTranslations } from "next-intl"
|
||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -14,44 +13,20 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
||||||
import type { ClassScheduleItem, TeacherClass } from "../types"
|
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||||||
import {
|
import { getPositionStyle, getSubjectColor, SCHEDULE_WEEKDAYS } from "./schedule-utils"
|
||||||
createClassScheduleItemAction,
|
import { ScheduleCreateDialog } from "./schedule-create-dialog"
|
||||||
deleteClassScheduleItemAction,
|
import { ScheduleEditDialog } from "./schedule-edit-dialog"
|
||||||
updateClassScheduleItemAction,
|
import { ScheduleDeleteDialog } from "./schedule-delete-dialog"
|
||||||
} from "../actions"
|
|
||||||
|
|
||||||
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
|
|
||||||
{ key: 1, label: "Mon" },
|
|
||||||
{ key: 2, label: "Tue" },
|
|
||||||
{ key: 3, label: "Wed" },
|
|
||||||
{ key: 4, label: "Thu" },
|
|
||||||
{ key: 5, label: "Fri" },
|
|
||||||
{ key: 6, label: "Sat" },
|
|
||||||
{ key: 7, label: "Sun" },
|
|
||||||
]
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课表周视图(P1-13:已将创建/编辑/删除对话框拆分为独立组件)。
|
||||||
|
*
|
||||||
|
* 本组件仅负责:
|
||||||
|
* - 渲染时间轴与周列布局
|
||||||
|
* - 渲染课表块及其 hover 操作菜单
|
||||||
|
* - 维护当前打开的对话框状态(createOpen / editItem / deleteItem)
|
||||||
|
*/
|
||||||
export function ScheduleView({
|
export function ScheduleView({
|
||||||
schedule,
|
schedule,
|
||||||
classes,
|
classes,
|
||||||
@@ -59,139 +34,21 @@ export function ScheduleView({
|
|||||||
schedule: ClassScheduleItem[]
|
schedule: ClassScheduleItem[]
|
||||||
classes: TeacherClass[]
|
classes: TeacherClass[]
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const t = useTranslations("classes")
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
|
||||||
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
|
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
|
||||||
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
|
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
|
||||||
|
|
||||||
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [createClassId, setCreateClassId] = useState<string>("")
|
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
|
||||||
|
|
||||||
const [editClassId, setEditClassId] = useState<string>("")
|
|
||||||
const [editWeekday, setEditWeekday] = useState<string>("1")
|
|
||||||
|
|
||||||
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
|
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
|
||||||
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
||||||
|
|
||||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
|
||||||
if (editItem !== prevEditItem) {
|
|
||||||
setPrevEditItem(editItem)
|
|
||||||
if (editItem) {
|
|
||||||
setEditClassId(editItem.classId)
|
|
||||||
setEditWeekday(String(editItem.weekday))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
|
||||||
if (createOpen !== prevCreateOpen) {
|
|
||||||
setPrevCreateOpen(createOpen)
|
|
||||||
if (createOpen) {
|
|
||||||
setCreateClassId(defaultClassId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
||||||
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
for (const d of SCHEDULE_WEEKDAYS) byDay.set(d.key, [])
|
||||||
for (const item of schedule) byDay.get(item.weekday)?.push(item)
|
for (const item of schedule) byDay.get(item.weekday)?.push(item)
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
|
||||||
setIsWorking(true)
|
|
||||||
try {
|
|
||||||
formData.set("classId", createClassId || defaultClassId)
|
|
||||||
formData.set("weekday", String(createWeekday))
|
|
||||||
const res = await createClassScheduleItemAction(null, formData)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setCreateOpen(false)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to create schedule item")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to create schedule item")
|
|
||||||
} finally {
|
|
||||||
setIsWorking(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdate = async (formData: FormData) => {
|
|
||||||
if (!editItem) return
|
|
||||||
setIsWorking(true)
|
|
||||||
try {
|
|
||||||
formData.set("classId", editClassId)
|
|
||||||
formData.set("weekday", editWeekday)
|
|
||||||
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setEditItem(null)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to update schedule item")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to update schedule item")
|
|
||||||
} finally {
|
|
||||||
setIsWorking(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteItem) return
|
|
||||||
setIsWorking(true)
|
|
||||||
try {
|
|
||||||
const res = await deleteClassScheduleItemAction(deleteItem.id)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setDeleteItem(null)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to delete schedule item")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete schedule item")
|
|
||||||
} finally {
|
|
||||||
setIsWorking(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPositionStyle = (startTime: string, endTime: string) => {
|
|
||||||
// Range 8:00 (480 min) -> 18:00 (1080 min)
|
|
||||||
// Total duration: 600 min
|
|
||||||
const startParts = startTime.split(':').map(Number)
|
|
||||||
const endParts = endTime.split(':').map(Number)
|
|
||||||
|
|
||||||
const startMinutes = startParts[0] * 60 + startParts[1]
|
|
||||||
const endMinutes = endParts[0] * 60 + endParts[1]
|
|
||||||
|
|
||||||
const minTime = 8 * 60
|
|
||||||
const maxTime = 18 * 60
|
|
||||||
const totalDuration = maxTime - minTime
|
|
||||||
|
|
||||||
// Calculate percentage positions
|
|
||||||
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
|
|
||||||
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: `${top}%`,
|
|
||||||
height: `${height}%`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
|
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
|
||||||
|
|
||||||
// Predefined colors for different subjects to add visual variety
|
|
||||||
const getSubjectColor = (subject: string) => {
|
|
||||||
const s = subject.toLowerCase()
|
|
||||||
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
|
|
||||||
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
|
|
||||||
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
|
|
||||||
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
|
|
||||||
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
|
|
||||||
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
|
|
||||||
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[600px] flex flex-col">
|
<div className="h-[600px] flex flex-col">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -213,10 +70,10 @@ export function ScheduleView({
|
|||||||
|
|
||||||
{/* Days Columns */}
|
{/* Days Columns */}
|
||||||
<div className="flex-1 grid grid-cols-5">
|
<div className="flex-1 grid grid-cols-5">
|
||||||
{WEEKDAYS.slice(0, 5).map((d) => (
|
{SCHEDULE_WEEKDAYS.slice(0, 5).map((d) => (
|
||||||
<div key={d.key} className="flex flex-col h-full min-w-0">
|
<div key={d.key} className="flex flex-col h-full min-w-0">
|
||||||
<div className="flex items-center justify-center py-2 h-10 group">
|
<div className="flex items-center justify-center py-2 h-10 group">
|
||||||
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
|
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{t(d.label)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative h-full mx-1">
|
<div className="relative h-full mx-1">
|
||||||
@@ -247,14 +104,14 @@ export function ScheduleView({
|
|||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
|
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0">
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-32">
|
<DropdownMenuContent align="end" className="w-32">
|
||||||
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
||||||
<Pencil className="mr-2 h-3 w-3" />
|
<Pencil className="mr-2 h-3 w-3" />
|
||||||
Edit
|
{t("list.actions.edit")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -262,7 +119,7 @@ export function ScheduleView({
|
|||||||
onClick={() => setDeleteItem(item)}
|
onClick={() => setDeleteItem(item)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-3 w-3" />
|
<Trash2 className="mr-2 h-3 w-3" />
|
||||||
Delete
|
{t("list.actions.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -274,20 +131,20 @@ export function ScheduleView({
|
|||||||
|
|
||||||
{/* Add Button Overlay - Only visible on hover of the column */}
|
{/* Add Button Overlay - Only visible on hover of the column */}
|
||||||
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
||||||
disabled={classes.length === 0}
|
disabled={classes.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreateWeekday(d.key)
|
setCreateWeekday(d.key)
|
||||||
setCreateOpen(true)
|
setCreateOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,231 +152,24 @@ export function ScheduleView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<ScheduleCreateDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={setCreateOpen}
|
||||||
if (isWorking) return
|
classes={classes}
|
||||||
setCreateOpen(v)
|
defaultClassId={defaultClassId}
|
||||||
}}
|
weekday={createWeekday}
|
||||||
>
|
/>
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add schedule item</DialogTitle>
|
|
||||||
<DialogDescription>Create a class schedule entry.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={handleCreate}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Class</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={createClassId} onValueChange={setCreateClassId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a class" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="classId" value={createClassId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<ScheduleEditDialog
|
||||||
<Label className="text-right">Weekday</Label>
|
editItem={editItem}
|
||||||
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
|
classes={classes}
|
||||||
<input type="hidden" name="weekday" value={String(createWeekday)} />
|
onClose={() => setEditItem(null)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<ScheduleDeleteDialog
|
||||||
<Label htmlFor="create-startTime" className="text-right">
|
deleteItem={deleteItem}
|
||||||
Start
|
onClose={() => setDeleteItem(null)}
|
||||||
</Label>
|
/>
|
||||||
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-endTime" className="text-right">
|
|
||||||
End
|
|
||||||
</Label>
|
|
||||||
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-course" className="text-right">
|
|
||||||
Course
|
|
||||||
</Label>
|
|
||||||
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-location" className="text-right">
|
|
||||||
Location
|
|
||||||
</Label>
|
|
||||||
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking || !createClassId}>
|
|
||||||
{isWorking ? "Creating..." : "Create"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={!!editItem}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
if (isWorking) return
|
|
||||||
if (!v) setEditItem(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit schedule item</DialogTitle>
|
|
||||||
<DialogDescription>Update class schedule entry.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={handleUpdate}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Class</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={editClassId} onValueChange={setEditClassId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a class" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="classId" value={editClassId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-weekday" className="text-right">
|
|
||||||
Weekday
|
|
||||||
</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select weekday" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">Mon</SelectItem>
|
|
||||||
<SelectItem value="2">Tue</SelectItem>
|
|
||||||
<SelectItem value="3">Wed</SelectItem>
|
|
||||||
<SelectItem value="4">Thu</SelectItem>
|
|
||||||
<SelectItem value="5">Fri</SelectItem>
|
|
||||||
<SelectItem value="6">Sat</SelectItem>
|
|
||||||
<SelectItem value="7">Sun</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="weekday" value={editWeekday} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-startTime" className="text-right">
|
|
||||||
Start
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-startTime"
|
|
||||||
name="startTime"
|
|
||||||
type="time"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem?.startTime}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-endTime" className="text-right">
|
|
||||||
End
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-endTime"
|
|
||||||
name="endTime"
|
|
||||||
type="time"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem?.endTime}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-course" className="text-right">
|
|
||||||
Course
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-course"
|
|
||||||
name="course"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem?.course}
|
|
||||||
placeholder="e.g. Math"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-location" className="text-right">
|
|
||||||
Location
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-location"
|
|
||||||
name="location"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem?.location ?? ""}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking || !editClassId}>
|
|
||||||
{isWorking ? "Saving..." : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!deleteItem}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
if (isWorking) return
|
|
||||||
if (!v) setDeleteItem(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently delete this schedule item.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleDelete()
|
|
||||||
}}
|
|
||||||
disabled={isWorking}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{isWorking ? "Deleting..." : "Delete"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -42,6 +43,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnroll = async (formData: FormData) => {
|
const handleEnroll = async (formData: FormData): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
|
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
|
||||||
@@ -66,19 +68,19 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to add student")
|
toast.error(res.message || t("list.failedCreate"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to add student")
|
toast.error(t("list.failedCreate"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedClass = classes.find(c => c.id === classId)
|
const selectedClass = classes.find(c => c.id === classId)
|
||||||
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
|
const classLabel = classId === "all" ? t("filters.allClasses") : (selectedClass?.name || t("filters.unknownClass"))
|
||||||
|
|
||||||
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
const statusLabel = status === "all" ? t("filters.allStatuses") : (status === "active" ? t("students.status.active") : t("students.status.inactive"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
@@ -87,7 +89,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
|
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search students..."
|
placeholder={t("students.searchPlaceholder")}
|
||||||
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
|
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value || null)}
|
onChange={(e) => setSearch(e.target.value || null)}
|
||||||
@@ -110,7 +112,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
onClick={() => setClassId("all")}
|
onClick={() => setClassId("all")}
|
||||||
className="text-xs flex items-center justify-between"
|
className="text-xs flex items-center justify-between"
|
||||||
>
|
>
|
||||||
All Classes
|
{t("filters.allClasses")}
|
||||||
{classId === "all" && <Check className="h-3 w-3" />}
|
{classId === "all" && <Check className="h-3 w-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -136,17 +138,17 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">{t("filters.filterByStatus")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
|
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
|
||||||
All Status
|
{t("filters.allStatuses")}
|
||||||
{status === "all" && <Check className="h-3 w-3" />}
|
{status === "all" && <Check className="h-3 w-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
|
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
|
||||||
Active
|
{t("students.status.active")}
|
||||||
{status === "active" && <Check className="h-3 w-3" />}
|
{status === "active" && <Check className="h-3 w-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
|
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
|
||||||
Inactive
|
{t("students.status.inactive")}
|
||||||
{status === "inactive" && <Check className="h-3 w-3" />}
|
{status === "inactive" && <Check className="h-3 w-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -163,22 +165,22 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
||||||
<UserPlus className="size-3.5" />
|
<UserPlus className="size-3.5" />
|
||||||
Add student
|
{t("filters.addStudent")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[520px]">
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add student</DialogTitle>
|
<DialogTitle>{t("filters.addStudent")}</DialogTitle>
|
||||||
<DialogDescription>Enroll a student by email to a class.</DialogDescription>
|
<DialogDescription>{t("filters.enrollStudentDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleEnroll}>
|
<form action={handleEnroll}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label className="text-right">Class</Label>
|
<Label className="text-right">{t("filters.class")}</Label>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
|
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a class" />
|
<SelectValue placeholder={t("filters.selectClass")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
@@ -192,21 +194,21 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="student-email" className="text-right">
|
<Label htmlFor="student-email" className="text-right">
|
||||||
Email
|
{t("filters.email")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="student-email"
|
id="student-email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
placeholder="student@example.com"
|
placeholder={t("filters.emailPlaceholder")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isWorking || !enrollClassId}>
|
<Button type="submit" disabled={isWorking || !enrollClassId}>
|
||||||
{isWorking ? "Adding..." : "Add"}
|
{isWorking ? t("form.adding") : t("form.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ import { setStudentEnrollmentStatusAction } from "../actions"
|
|||||||
|
|
||||||
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("classes")
|
||||||
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
||||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
toast.error(res.message)
|
toast.error(res.message)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update status")
|
toast.error(t("list.failedStatus"))
|
||||||
} finally {
|
} finally {
|
||||||
setWorkingKey(null)
|
setWorkingKey(null)
|
||||||
}
|
}
|
||||||
@@ -124,12 +126,12 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
{s.status !== "active" ? (
|
{s.status !== "active" ? (
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
||||||
<UserCheck className="mr-2 size-4" />
|
<UserCheck className="mr-2 size-4" />
|
||||||
Set active
|
{t("students.actions.setActive")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
||||||
<UserX className="mr-2 size-4" />
|
<UserX className="mr-2 size-4" />
|
||||||
Set inactive
|
{t("students.actions.setInactive")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -139,7 +141,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
disabled={s.status === "inactive" || workingKey !== null}
|
disabled={s.status === "inactive" || workingKey !== null}
|
||||||
>
|
>
|
||||||
<UserX className="mr-2 size-4" />
|
<UserX className="mr-2 size-4" />
|
||||||
Remove from class
|
{t("students.actions.remove")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -154,15 +156,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
if (workingKey !== null) return
|
if (workingKey !== null) return
|
||||||
if (!open) setRemoveTarget(null)
|
if (!open) setRemoveTarget(null)
|
||||||
}}
|
}}
|
||||||
title="Remove student from class?"
|
title={t("students.removeTitle")}
|
||||||
confirmText="Remove"
|
confirmText={t("students.actions.remove")}
|
||||||
description={
|
description={
|
||||||
removeTarget ? (
|
removeTarget ? t("students.removeDescription", { name: removeTarget.name }) : null
|
||||||
<>
|
|
||||||
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
|
|
||||||
<span className="font-medium text-foreground">{removeTarget.className}</span>.
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (!removeTarget) return
|
if (!removeTarget) return
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
usersToRoles,
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
import type {
|
import type {
|
||||||
AdminClassListItem,
|
AdminClassListItem,
|
||||||
@@ -28,6 +29,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
compareClassLike,
|
compareClassLike,
|
||||||
generateUniqueInvitationCode,
|
generateUniqueInvitationCode,
|
||||||
|
getClassSubjects,
|
||||||
isDuplicateInvitationCodeError,
|
isDuplicateInvitationCodeError,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
|
|
||||||
@@ -343,21 +345,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
|||||||
.from(users)
|
.from(users)
|
||||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
|
.where(and(eq(users.id, teacherId), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!teacher) throw new Error("Teacher not found")
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
const invitationCode = await generateUniqueInvitationCode()
|
const invitationCode = await generateUniqueInvitationCode()
|
||||||
try {
|
try {
|
||||||
|
const subjectNames = await getClassSubjects()
|
||||||
const subjectRows = await db
|
const subjectRows = await db
|
||||||
.select({ id: subjects.id, name: subjects.name })
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
.from(subjects)
|
.from(subjects)
|
||||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
.where(inArray(subjects.name, subjectNames))
|
||||||
const idByName = new Map<ClassSubject, string>()
|
const idByName = new Map<string, string>()
|
||||||
for (const r of subjectRows) {
|
for (const r of subjectRows) {
|
||||||
const subject = toClassSubject(r.name)
|
idByName.set(r.name, r.id)
|
||||||
if (subject) idByName.set(subject, r.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
@@ -374,7 +376,7 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
|||||||
teacherId,
|
teacherId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
const values = subjectNames.flatMap((name) => {
|
||||||
const subjectId = idByName.get(name)
|
const subjectId = idByName.get(name)
|
||||||
if (!subjectId) return []
|
if (!subjectId) return []
|
||||||
return [{ classId: id, subjectId, teacherId: null }]
|
return [{ classId: id, subjectId, teacherId: null }]
|
||||||
@@ -422,7 +424,7 @@ export async function updateAdminClass(
|
|||||||
.from(users)
|
.from(users)
|
||||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
|
.where(and(eq(users.id, nextTeacherId), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!teacher) throw new Error("Teacher not found")
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,42 @@ export function isLegacyFormatCode(code: string): boolean {
|
|||||||
return /^\d{6}$/.test(code.trim())
|
return /^\d{6}$/.test(code.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Legacy 6 位数字邀请码(classes.invitationCode) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断错误是否为邀请码重复错误(旧 6 位数字码,classes.invitationCode 唯一约束)。
|
||||||
|
*/
|
||||||
|
export const isDuplicateInvitationCodeError = (err: unknown): boolean => {
|
||||||
|
if (!err) return false
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
const m = msg.toLowerCase()
|
||||||
|
return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateInvitationCode = (): string => {
|
||||||
|
const n = Math.floor(Math.random() * 1_000_000)
|
||||||
|
return String(n).padStart(6, "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一邀请码(旧 6 位数字格式,classes.invitationCode)。
|
||||||
|
* DB unique 约束 + 40 次重试。
|
||||||
|
*/
|
||||||
|
export const generateUniqueInvitationCode = async (): Promise<string> => {
|
||||||
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||||
|
const code = generateInvitationCode()
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.invitationCode, code))
|
||||||
|
.limit(1)
|
||||||
|
if (!existing) return code
|
||||||
|
}
|
||||||
|
throw new Error("Failed to generate invitation code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 新格式邀请码(class_invitation_codes 表) ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成唯一邀请码(带重试)。
|
* 生成唯一邀请码(带重试)。
|
||||||
* DB unique 约束 + 40 次重试(沿用现有模式)。
|
* DB unique 约束 + 40 次重试(沿用现有模式)。
|
||||||
@@ -235,7 +271,9 @@ export async function validateInvitationCode(code: string): Promise<ValidationRe
|
|||||||
}
|
}
|
||||||
// 已禁用
|
// 已禁用
|
||||||
if (record.status !== "active") {
|
if (record.status !== "active") {
|
||||||
return { valid: false, reason: record.status as ValidationResult["reason"] }
|
// P2-A: record.status 经 !== "active" 收窄后为 "disabled" | "expired" | "exhausted",
|
||||||
|
// 均属于 ValidationResult["reason"] 联合类型,无需 as 断言
|
||||||
|
return { valid: false, reason: record.status }
|
||||||
}
|
}
|
||||||
return { valid: true, classId: record.classId, codeId: record.id }
|
return { valid: true, classId: record.classId, codeId: record.id }
|
||||||
}
|
}
|
||||||
@@ -310,21 +348,22 @@ export async function purgeExpiredCodes(): Promise<number> {
|
|||||||
|
|
||||||
// MySqlRawQueryResult 是 [rows, fields] 元组,rows 可能含 affectedRows
|
// MySqlRawQueryResult 是 [rows, fields] 元组,rows 可能含 affectedRows
|
||||||
const rows = Array.isArray(result) ? result[0] : result
|
const rows = Array.isArray(result) ? result[0] : result
|
||||||
const affectedRows =
|
// P2-A: 使用 in 操作符收窄 unknown,避免 `(rows as { affectedRows: unknown }).affectedRows` 断言
|
||||||
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
if (typeof rows === "object" && rows !== null && "affectedRows" in rows) {
|
||||||
? Number((rows as { affectedRows: unknown }).affectedRows)
|
return Number(rows.affectedRows)
|
||||||
: 0
|
}
|
||||||
return affectedRows
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ helpers ============
|
// ============ helpers ============
|
||||||
|
|
||||||
function mapRecord(row: typeof classInvitationCodes.$inferSelect): InvitationCodeRecord {
|
function mapRecord(row: typeof classInvitationCodes.$inferSelect): InvitationCodeRecord {
|
||||||
|
// P2-A: schema 中 status 列为 mysqlEnum,推断类型即 InvitationCodeStatus,无需 as 断言
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
classId: row.classId,
|
classId: row.classId,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
status: row.status as InvitationCodeStatus,
|
status: row.status,
|
||||||
maxUses: row.maxUses,
|
maxUses: row.maxUses,
|
||||||
usedCount: row.usedCount,
|
usedCount: row.usedCount,
|
||||||
expiresAt: row.expiresAt,
|
expiresAt: row.expiresAt,
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ import {
|
|||||||
getSessionTeacherId,
|
getSessionTeacherId,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据课表项 ID 获取其所属班级 ID(P0-3 审计修复:供 actions-schedule 越权校验使用)。
|
||||||
|
* classes 模块对 classSchedule 表有读权限(usedBy 含 classes)。
|
||||||
|
*/
|
||||||
|
export async function getClassIdByScheduleId(scheduleId: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ classId: classSchedule.classId })
|
||||||
|
.from(classSchedule)
|
||||||
|
.where(eq(classSchedule.id, scheduleId))
|
||||||
|
.limit(1)
|
||||||
|
return row?.classId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const isWeekday = (n: unknown): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
const isWeekday = (n: unknown): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||||
typeof n === "number" && n >= 1 && n <= 7 && Number.isInteger(n)
|
typeof n === "number" && n >= 1 && n <= 7 && Number.isInteger(n)
|
||||||
|
|
||||||
|
|||||||
@@ -294,3 +294,62 @@ export const getClassStudents = cache(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DataScope resolver helpers (P1-5/P1-6 audit fix)
|
||||||
|
// These lightweight functions return only the IDs needed by the RBAC
|
||||||
|
// data-scope resolver, so shared/lib/auth-guard no longer queries classes/
|
||||||
|
// classEnrollments/classSubjectTeachers tables directly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a student's class IDs and grade IDs for DataScope resolution.
|
||||||
|
* Joins classEnrollments with classes to resolve gradeId per enrollment.
|
||||||
|
*/
|
||||||
|
export async function getStudentScopeData(
|
||||||
|
studentId: string,
|
||||||
|
): Promise<{ classIds: string[]; gradeIds: string[] }> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||||
|
.where(eq(classEnrollments.studentId, studentId))
|
||||||
|
|
||||||
|
const classIds = rows.map((r) => r.classId)
|
||||||
|
const gradeIdSet = new Set<string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.gradeId !== null && row.gradeId.trim().length > 0) {
|
||||||
|
gradeIdSet.add(row.gradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
classIds,
|
||||||
|
gradeIds: Array.from(gradeIdSet),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get grade IDs for a list of student IDs (used by parent DataScope).
|
||||||
|
* Queries classEnrollments JOIN classes for all students in a single query.
|
||||||
|
*/
|
||||||
|
export async function getGradeIdsForStudentIds(
|
||||||
|
studentIds: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (studentIds.length === 0) return []
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ gradeId: classes.gradeId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||||
|
.where(inArray(classEnrollments.studentId, studentIds))
|
||||||
|
|
||||||
|
const gradeIdSet = new Set<string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.gradeId !== null && row.gradeId.trim().length > 0) {
|
||||||
|
gradeIdSet.add(row.gradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(gradeIdSet)
|
||||||
|
}
|
||||||
|
|||||||
616
src/modules/classes/data-access-teacher.ts
Normal file
616
src/modules/classes/data-access-teacher.ts
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
classSubjectTeachers,
|
||||||
|
subjects,
|
||||||
|
roles,
|
||||||
|
users,
|
||||||
|
usersToRoles,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||||
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
|
import type {
|
||||||
|
ClassSubject,
|
||||||
|
CreateTeacherClassInput,
|
||||||
|
TeacherOption,
|
||||||
|
TeacherClass,
|
||||||
|
UpdateTeacherClassInput,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
compareClassLike,
|
||||||
|
getAccessibleClassIdsForTeacher,
|
||||||
|
getClassSubjects,
|
||||||
|
getSessionTeacherId,
|
||||||
|
getTeacherIdForMutations,
|
||||||
|
} from "./data-access"
|
||||||
|
import { getClassHomeworkInsights } from "./data-access-stats"
|
||||||
|
import { getClassSchedule } from "./data-access-schedule"
|
||||||
|
import {
|
||||||
|
generateUniqueInvitationCode,
|
||||||
|
isDuplicateInvitationCodeError,
|
||||||
|
} from "./data-access-invitations"
|
||||||
|
|
||||||
|
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 getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||||
|
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||||
|
if (!teacherId) return []
|
||||||
|
|
||||||
|
const rows = await (async () => {
|
||||||
|
try {
|
||||||
|
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
|
|
||||||
|
if (allIds.length === 0) return []
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: classes.schoolName,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: classes.invitationCode,
|
||||||
|
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
|
.where(inArray(classes.id, allIds))
|
||||||
|
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||||
|
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("getTeacherClasses query failed:", error)
|
||||||
|
throw new Error("Failed to load teacher classes")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const list = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
schoolName: r.schoolName,
|
||||||
|
name: r.name,
|
||||||
|
grade: r.grade,
|
||||||
|
homeroom: r.homeroom,
|
||||||
|
room: r.room,
|
||||||
|
invitationCode: r.invitationCode ?? null,
|
||||||
|
studentCount: Number(r.studentCount ?? 0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
list.sort(compareClassLike)
|
||||||
|
|
||||||
|
// Fetch recent assignments for trends and schedule
|
||||||
|
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 recentAssignments = insights
|
||||||
|
? insights.assignments.map((a) => ({
|
||||||
|
id: a.assignmentId,
|
||||||
|
title: a.title,
|
||||||
|
status: a.status,
|
||||||
|
subject: a.subject,
|
||||||
|
isActive: a.isActive,
|
||||||
|
isOverdue: a.isOverdue,
|
||||||
|
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||||
|
submittedCount: a.submittedCount,
|
||||||
|
targetCount: a.targetCount,
|
||||||
|
avgScore: a.scoreStats.avg,
|
||||||
|
medianScore: a.scoreStats.median,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
return { ...c, recentAssignments, schedule }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return listWithTrends
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: users.id, name: users.name, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(roles.name, ROLE_NAMES.TEACHER))
|
||||||
|
.orderBy(asc(users.createdAt))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name ?? "Unnamed",
|
||||||
|
email: r.email,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
|
||||||
|
const teacherId = await getSessionTeacherId()
|
||||||
|
if (!teacherId) return []
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ subject: subjects.name })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
|
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||||
|
.groupBy(subjects.name)
|
||||||
|
.orderBy(asc(subjects.name))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((r) => toClassSubject(r.subject))
|
||||||
|
.filter((s): s is ClassSubject => s !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
const id = createId()
|
||||||
|
|
||||||
|
const schoolName = data.schoolName?.trim() || null
|
||||||
|
const schoolId = data.schoolId?.trim() || null
|
||||||
|
const name = data.name.trim()
|
||||||
|
const grade = data.grade.trim()
|
||||||
|
const gradeId = data.gradeId?.trim() || null
|
||||||
|
const homeroom = data.homeroom?.trim() || null
|
||||||
|
const room = data.room?.trim() || null
|
||||||
|
|
||||||
|
if (!name) throw new Error("Name is required")
|
||||||
|
if (!grade) throw new Error("Grade is required")
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const invitationCode = await generateUniqueInvitationCode()
|
||||||
|
try {
|
||||||
|
const subjectNames = await getClassSubjects()
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.name, subjectNames))
|
||||||
|
const idByName = new Map<string, string>()
|
||||||
|
for (const r of subjectRows) {
|
||||||
|
idByName.set(r.name, r.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(classes).values({
|
||||||
|
id,
|
||||||
|
schoolName,
|
||||||
|
schoolId,
|
||||||
|
name,
|
||||||
|
grade,
|
||||||
|
gradeId,
|
||||||
|
homeroom,
|
||||||
|
room,
|
||||||
|
invitationCode,
|
||||||
|
teacherId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = subjectNames.flatMap((name) => {
|
||||||
|
const subjectId = idByName.get(name)
|
||||||
|
if (!subjectId) return []
|
||||||
|
return [{ classId: id, subjectId, teacherId: null }]
|
||||||
|
})
|
||||||
|
await tx.insert(classSubjectTeachers).values(values)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
} catch (err) {
|
||||||
|
if (isDuplicateInvitationCodeError(err)) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Failed to create class")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureClassInvitationCode(classId: string): Promise<string> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
const id = classId.trim()
|
||||||
|
if (!id) throw new Error("Missing class id")
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id, invitationCode: classes.invitationCode })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const existing = owned.invitationCode
|
||||||
|
if (typeof existing === "string" && /^\d{6}$/.test(existing)) return existing
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||||
|
const code = await generateUniqueInvitationCode()
|
||||||
|
try {
|
||||||
|
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
||||||
|
return code
|
||||||
|
} catch (err) {
|
||||||
|
if (isDuplicateInvitationCodeError(err)) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to generate invitation code")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateClassInvitationCode(classId: string): Promise<string> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
const id = classId.trim()
|
||||||
|
if (!id) throw new Error("Missing class id")
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||||
|
const code = await generateUniqueInvitationCode()
|
||||||
|
try {
|
||||||
|
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
||||||
|
return code
|
||||||
|
} catch (err) {
|
||||||
|
if (isDuplicateInvitationCodeError(err)) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to generate invitation code")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollStudentByInvitationCode(studentId: string, invitationCode: string): Promise<string> {
|
||||||
|
const sid = studentId.trim()
|
||||||
|
const code = invitationCode.trim()
|
||||||
|
if (!sid) throw new Error("Missing student id")
|
||||||
|
if (!code) throw new Error("Invalid invitation code")
|
||||||
|
|
||||||
|
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||||
|
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||||
|
const result = await validateInvitationCode(code)
|
||||||
|
if (!result.valid || !result.classId) {
|
||||||
|
throw new Error("Invalid invitation code")
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(classEnrollments)
|
||||||
|
.values({ classId: result.classId, studentId: sid, status: "active" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||||
|
|
||||||
|
// 消耗新表邀请码(旧表无计数,跳过)
|
||||||
|
if (result.codeId) {
|
||||||
|
await consumeInvitationCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.classId
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollTeacherByInvitationCode(
|
||||||
|
teacherId: string,
|
||||||
|
invitationCode: string,
|
||||||
|
subject: string | null
|
||||||
|
): Promise<string> {
|
||||||
|
const tid = teacherId.trim()
|
||||||
|
const code = invitationCode.trim()
|
||||||
|
if (!tid) throw new Error("Missing teacher id")
|
||||||
|
if (!code) throw new Error("Invalid invitation code")
|
||||||
|
|
||||||
|
const [teacher] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, tid), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
|
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||||
|
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||||
|
const result = await validateInvitationCode(code)
|
||||||
|
if (!result.valid || !result.classId) {
|
||||||
|
throw new Error("Invalid invitation code")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cls] = await db
|
||||||
|
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, result.classId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cls) throw new Error("Invalid invitation code")
|
||||||
|
if (cls.teacherId === tid) return cls.id
|
||||||
|
|
||||||
|
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
||||||
|
const [existing] = await db
|
||||||
|
.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 (subjectValue) {
|
||||||
|
const [subRow] = await db.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
|
||||||
|
.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) {
|
||||||
|
await db
|
||||||
|
.insert(classSubjectTeachers)
|
||||||
|
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
||||||
|
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingSubject] = await db
|
||||||
|
.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
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(classSubjectTeachers)
|
||||||
|
.set({ teacherId: tid })
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
||||||
|
|
||||||
|
const [assigned] = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!assigned) throw new Error("Subject already assigned")
|
||||||
|
} else {
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
|
||||||
|
|
||||||
|
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
||||||
|
if (!preferred) throw new Error("Class already has assigned teachers")
|
||||||
|
const subjectRow = subjectRows.find((r) => r.name === preferred)
|
||||||
|
if (!subjectRow) throw new Error("Subject not found")
|
||||||
|
const sid = subjectRow.id
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(classSubjectTeachers)
|
||||||
|
.set({ teacherId: tid })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(classSubjectTeachers.classId, cls.id),
|
||||||
|
eq(classSubjectTeachers.subjectId, sid),
|
||||||
|
isNull(classSubjectTeachers.teacherId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [assigned] = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(classSubjectTeachers.classId, cls.id),
|
||||||
|
eq(classSubjectTeachers.subjectId, sid),
|
||||||
|
eq(classSubjectTeachers.teacherId, tid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消耗新表邀请码(旧表无计数,跳过)
|
||||||
|
if (result.codeId) {
|
||||||
|
await consumeInvitationCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const update: Partial<typeof classes.$inferSelect> = {}
|
||||||
|
if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null
|
||||||
|
if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null
|
||||||
|
if (typeof data.name === "string") update.name = data.name.trim()
|
||||||
|
if (typeof data.grade === "string") update.grade = data.grade.trim()
|
||||||
|
if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null
|
||||||
|
if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null
|
||||||
|
if (data.room !== undefined) update.room = data.room?.trim() || null
|
||||||
|
|
||||||
|
if (Object.keys(update).length === 0) return
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(classes)
|
||||||
|
.set(update)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setClassSubjectTeachers(params: {
|
||||||
|
classId: string
|
||||||
|
assignments: Array<{ subject: ClassSubject; teacherId: string | null }>
|
||||||
|
}): Promise<void> {
|
||||||
|
const classId = params.classId.trim()
|
||||||
|
if (!classId) throw new Error("Missing class id")
|
||||||
|
|
||||||
|
const [existing] = await db.select({ id: classes.id }).from(classes).where(eq(classes.id, classId)).limit(1)
|
||||||
|
if (!existing) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const teacherIds = params.assignments
|
||||||
|
.map((a) => a.teacherId)
|
||||||
|
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
||||||
|
|
||||||
|
if (teacherIds.length > 0) {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(roles.name, ROLE_NAMES.TEACHER), inArray(users.id, teacherIds)))
|
||||||
|
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const teacherBySubject = new Map<string, string | null>()
|
||||||
|
const subjectNames = await getClassSubjects()
|
||||||
|
for (const a of params.assignments) {
|
||||||
|
if (!subjectNames.includes(a.subject)) continue
|
||||||
|
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map subject names to ids
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.name, subjectNames))
|
||||||
|
const idByName = new Map<string, string>()
|
||||||
|
for (const r of subjectRows) {
|
||||||
|
idByName.set(r.name, r.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = subjectNames.flatMap((name) => {
|
||||||
|
const subjectId = idByName.get(name)
|
||||||
|
if (!subjectId) return []
|
||||||
|
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(classSubjectTeachers)
|
||||||
|
.values(values)
|
||||||
|
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeacherClass(classId: string): Promise<void> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(classes)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollStudentByEmail(classId: string, email: string): Promise<void> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
const normalized = email.trim().toLowerCase()
|
||||||
|
if (!normalized) throw new Error("Student email is required")
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const [student] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, normalized))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!student) throw new Error("Student not found")
|
||||||
|
const [studentRole] = await db
|
||||||
|
.select({ id: usersToRoles.userId })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, ROLE_NAMES.STUDENT)))
|
||||||
|
.limit(1)
|
||||||
|
if (!studentRole) throw new Error("User is not a student")
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(classEnrollments)
|
||||||
|
.values({ classId, studentId: student.id, status: "active" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStudentEnrollmentStatus(classId: string, studentId: string, status: "active" | "inactive"): Promise<void> {
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ classId: classEnrollments.classId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Enrollment not found")
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(classEnrollments)
|
||||||
|
.set({ status })
|
||||||
|
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DataScope resolver helpers (P1-5/P1-6 audit fix)
|
||||||
|
// These lightweight functions return only the IDs needed by the RBAC
|
||||||
|
// data-scope resolver, so shared/lib/auth-guard no longer queries classes/
|
||||||
|
// classEnrollments/classSubjectTeachers tables directly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a teacher's class IDs and subject IDs for DataScope resolution.
|
||||||
|
* Merges homeroom classes (classes.teacherId) and subject-teacher assignments
|
||||||
|
* (classSubjectTeachers.teacherId). Returns deduplicated arrays.
|
||||||
|
*/
|
||||||
|
export async function getTeacherScopeData(
|
||||||
|
teacherId: string,
|
||||||
|
): Promise<{ classIds: string[]; subjectIds: string[] }> {
|
||||||
|
const [homeroomRows, subjectRows] = await Promise.all([
|
||||||
|
db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)),
|
||||||
|
db
|
||||||
|
.select({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(eq(classSubjectTeachers.teacherId, teacherId)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const classIdSet = new Set<string>(homeroomRows.map((r) => r.id))
|
||||||
|
const subjectIdSet = new Set<string>()
|
||||||
|
for (const row of subjectRows) {
|
||||||
|
classIdSet.add(row.classId)
|
||||||
|
if (row.subjectId !== null) subjectIdSet.add(row.subjectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
classIds: Array.from(classIdSet),
|
||||||
|
subjectIds: Array.from(subjectIdSet),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,18 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { cache } from "react"
|
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
|
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
classes,
|
classes,
|
||||||
classEnrollments,
|
classEnrollments,
|
||||||
classSubjectTeachers,
|
classSubjectTeachers,
|
||||||
subjects,
|
|
||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
usersToRoles,
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
import type {
|
|
||||||
ClassSubject,
|
|
||||||
CreateTeacherClassInput,
|
|
||||||
TeacherOption,
|
|
||||||
TeacherClass,
|
|
||||||
UpdateTeacherClassInput,
|
|
||||||
} from "./types"
|
|
||||||
import { getClassHomeworkInsights } from "./data-access-stats"
|
|
||||||
import { getClassSchedule } from "./data-access-schedule"
|
|
||||||
|
|
||||||
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 getSessionTeacherId = async (): Promise<string | null> => {
|
export const getSessionTeacherId = async (): Promise<string | null> => {
|
||||||
const { auth } = await import("@/auth")
|
const { auth } = await import("@/auth")
|
||||||
@@ -42,38 +25,11 @@ export const getSessionTeacherId = async (): Promise<string | null> => {
|
|||||||
.from(users)
|
.from(users)
|
||||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
|
.where(and(eq(users.id, userId), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
return teacher?.id ?? null
|
return teacher?.id ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict subjectId-based mapping: no aliasing
|
|
||||||
|
|
||||||
export const isDuplicateInvitationCodeError = (err: unknown): boolean => {
|
|
||||||
if (!err) return false
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
const m = msg.toLowerCase()
|
|
||||||
return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code"))
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateInvitationCode = (): string => {
|
|
||||||
const n = Math.floor(Math.random() * 1_000_000)
|
|
||||||
return String(n).padStart(6, "0")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const generateUniqueInvitationCode = async (): Promise<string> => {
|
|
||||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
||||||
const code = generateInvitationCode()
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(eq(classes.invitationCode, code))
|
|
||||||
.limit(1)
|
|
||||||
if (!existing) return code
|
|
||||||
}
|
|
||||||
throw new Error("Failed to generate invitation code")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTeacherIdForMutations = async (): Promise<string> => {
|
export const getTeacherIdForMutations = async (): Promise<string> => {
|
||||||
const teacherId = await getSessionTeacherId()
|
const teacherId = await getSessionTeacherId()
|
||||||
if (!teacherId) throw new Error("Teacher not found")
|
if (!teacherId) throw new Error("Teacher not found")
|
||||||
@@ -86,7 +42,8 @@ export const getClassSubjects = async (): Promise<string[]> => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
|
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
|
||||||
return Array.from(new Set(names))
|
// P1-5: subjects 表为空时回退到默认科目列表,避免班级创建流程中断
|
||||||
|
return Array.from(new Set(names.length > 0 ? names : DEFAULT_CLASS_SUBJECTS))
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeSortText = (v: string | null | undefined): string =>
|
const normalizeSortText = (v: string | null | undefined): string =>
|
||||||
@@ -423,544 +380,27 @@ export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => {
|
|||||||
return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds))
|
return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
|
||||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
|
||||||
if (!teacherId) return []
|
|
||||||
|
|
||||||
const rows = await (async () => {
|
|
||||||
try {
|
|
||||||
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
|
|
||||||
|
|
||||||
if (allIds.length === 0) return []
|
|
||||||
|
|
||||||
return await db
|
|
||||||
.select({
|
|
||||||
id: classes.id,
|
|
||||||
schoolName: classes.schoolName,
|
|
||||||
name: classes.name,
|
|
||||||
grade: classes.grade,
|
|
||||||
homeroom: classes.homeroom,
|
|
||||||
room: classes.room,
|
|
||||||
invitationCode: classes.invitationCode,
|
|
||||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
|
||||||
})
|
|
||||||
.from(classes)
|
|
||||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
|
||||||
.where(inArray(classes.id, allIds))
|
|
||||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
|
||||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
|
||||||
} catch (error) {
|
|
||||||
console.error("getTeacherClasses query failed:", error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const list = rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
schoolName: r.schoolName,
|
|
||||||
name: r.name,
|
|
||||||
grade: r.grade,
|
|
||||||
homeroom: r.homeroom,
|
|
||||||
room: r.room,
|
|
||||||
invitationCode: r.invitationCode ?? null,
|
|
||||||
studentCount: Number(r.studentCount ?? 0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
list.sort(compareClassLike)
|
|
||||||
|
|
||||||
// Fetch recent assignments for trends and schedule
|
|
||||||
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 recentAssignments = insights
|
|
||||||
? insights.assignments.map((a) => ({
|
|
||||||
id: a.assignmentId,
|
|
||||||
title: a.title,
|
|
||||||
status: a.status,
|
|
||||||
subject: a.subject,
|
|
||||||
isActive: a.isActive,
|
|
||||||
isOverdue: a.isOverdue,
|
|
||||||
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
|
||||||
submittedCount: a.submittedCount,
|
|
||||||
targetCount: a.targetCount,
|
|
||||||
avgScore: a.scoreStats.avg,
|
|
||||||
medianScore: a.scoreStats.median,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
return { ...c, recentAssignments, schedule }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return listWithTrends
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
|
||||||
const rows = await db
|
|
||||||
.select({ id: users.id, name: users.name, email: users.email })
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(eq(roles.name, "teacher"))
|
|
||||||
.orderBy(asc(users.createdAt))
|
|
||||||
|
|
||||||
return rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
name: r.name ?? "Unnamed",
|
|
||||||
email: r.email,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
|
|
||||||
const teacherId = await getSessionTeacherId()
|
|
||||||
if (!teacherId) return []
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({ subject: subjects.name })
|
|
||||||
.from(classSubjectTeachers)
|
|
||||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
|
||||||
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
|
||||||
.groupBy(subjects.name)
|
|
||||||
.orderBy(asc(subjects.name))
|
|
||||||
|
|
||||||
return rows
|
|
||||||
.map((r) => toClassSubject(r.subject))
|
|
||||||
.filter((s): s is ClassSubject => s !== null)
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
const id = createId()
|
|
||||||
|
|
||||||
const schoolName = data.schoolName?.trim() || null
|
|
||||||
const schoolId = data.schoolId?.trim() || null
|
|
||||||
const name = data.name.trim()
|
|
||||||
const grade = data.grade.trim()
|
|
||||||
const gradeId = data.gradeId?.trim() || null
|
|
||||||
const homeroom = data.homeroom?.trim() || null
|
|
||||||
const room = data.room?.trim() || null
|
|
||||||
|
|
||||||
if (!name) throw new Error("Name is required")
|
|
||||||
if (!grade) throw new Error("Grade is required")
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
||||||
const invitationCode = await generateUniqueInvitationCode()
|
|
||||||
try {
|
|
||||||
const subjectRows = await db
|
|
||||||
.select({ id: subjects.id, name: subjects.name })
|
|
||||||
.from(subjects)
|
|
||||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
|
||||||
const idByName = new Map<ClassSubject, string>()
|
|
||||||
for (const r of subjectRows) {
|
|
||||||
const subject = toClassSubject(r.name)
|
|
||||||
if (subject) idByName.set(subject, r.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
await tx.insert(classes).values({
|
|
||||||
id,
|
|
||||||
schoolName,
|
|
||||||
schoolId,
|
|
||||||
name,
|
|
||||||
grade,
|
|
||||||
gradeId,
|
|
||||||
homeroom,
|
|
||||||
room,
|
|
||||||
invitationCode,
|
|
||||||
teacherId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
|
||||||
const subjectId = idByName.get(name)
|
|
||||||
if (!subjectId) return []
|
|
||||||
return [{ classId: id, subjectId, teacherId: null }]
|
|
||||||
})
|
|
||||||
await tx.insert(classSubjectTeachers).values(values)
|
|
||||||
})
|
|
||||||
return id
|
|
||||||
} catch (err) {
|
|
||||||
if (isDuplicateInvitationCodeError(err)) continue
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Failed to create class")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureClassInvitationCode(classId: string): Promise<string> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
const id = classId.trim()
|
|
||||||
if (!id) throw new Error("Missing class id")
|
|
||||||
|
|
||||||
const [owned] = await db
|
|
||||||
.select({ id: classes.id, invitationCode: classes.invitationCode })
|
|
||||||
.from(classes)
|
|
||||||
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
|
||||||
|
|
||||||
const existing = owned.invitationCode
|
|
||||||
if (typeof existing === "string" && /^\d{6}$/.test(existing)) return existing
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
||||||
const code = await generateUniqueInvitationCode()
|
|
||||||
try {
|
|
||||||
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
|
||||||
return code
|
|
||||||
} catch (err) {
|
|
||||||
if (isDuplicateInvitationCodeError(err)) continue
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Failed to generate invitation code")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function regenerateClassInvitationCode(classId: string): Promise<string> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
const id = classId.trim()
|
|
||||||
if (!id) throw new Error("Missing class id")
|
|
||||||
|
|
||||||
const [owned] = await db
|
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
||||||
const code = await generateUniqueInvitationCode()
|
|
||||||
try {
|
|
||||||
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
|
||||||
return code
|
|
||||||
} catch (err) {
|
|
||||||
if (isDuplicateInvitationCodeError(err)) continue
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Failed to generate invitation code")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enrollStudentByInvitationCode(studentId: string, invitationCode: string): Promise<string> {
|
|
||||||
const sid = studentId.trim()
|
|
||||||
const code = invitationCode.trim()
|
|
||||||
if (!sid) throw new Error("Missing student id")
|
|
||||||
if (!code) throw new Error("Invalid invitation code")
|
|
||||||
|
|
||||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
|
||||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
|
||||||
const result = await validateInvitationCode(code)
|
|
||||||
if (!result.valid || !result.classId) {
|
|
||||||
throw new Error("Invalid invitation code")
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(classEnrollments)
|
|
||||||
.values({ classId: result.classId, studentId: sid, status: "active" })
|
|
||||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
|
||||||
|
|
||||||
// 消耗新表邀请码(旧表无计数,跳过)
|
|
||||||
if (result.codeId) {
|
|
||||||
await consumeInvitationCode(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.classId
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enrollTeacherByInvitationCode(
|
|
||||||
teacherId: string,
|
|
||||||
invitationCode: string,
|
|
||||||
subject: string | null
|
|
||||||
): Promise<string> {
|
|
||||||
const tid = teacherId.trim()
|
|
||||||
const code = invitationCode.trim()
|
|
||||||
if (!tid) throw new Error("Missing teacher id")
|
|
||||||
if (!code) throw new Error("Invalid invitation code")
|
|
||||||
|
|
||||||
const [teacher] = await db
|
|
||||||
.select({ id: users.id })
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!teacher) throw new Error("Teacher not found")
|
|
||||||
|
|
||||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
|
||||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
|
||||||
const result = await validateInvitationCode(code)
|
|
||||||
if (!result.valid || !result.classId) {
|
|
||||||
throw new Error("Invalid invitation code")
|
|
||||||
}
|
|
||||||
|
|
||||||
const [cls] = await db
|
|
||||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
|
||||||
.from(classes)
|
|
||||||
.where(eq(classes.id, result.classId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!cls) throw new Error("Invalid invitation code")
|
|
||||||
if (cls.teacherId === tid) return cls.id
|
|
||||||
|
|
||||||
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
|
||||||
const [existing] = await db
|
|
||||||
.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 (subjectValue) {
|
|
||||||
const [subRow] = await db.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
|
|
||||||
.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) {
|
|
||||||
await db
|
|
||||||
.insert(classSubjectTeachers)
|
|
||||||
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
|
||||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingSubject] = await db
|
|
||||||
.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
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(classSubjectTeachers)
|
|
||||||
.set({ teacherId: tid })
|
|
||||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
|
||||||
|
|
||||||
const [assigned] = await db
|
|
||||||
.select({ id: classSubjectTeachers.classId })
|
|
||||||
.from(classSubjectTeachers)
|
|
||||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!assigned) throw new Error("Subject already assigned")
|
|
||||||
} else {
|
|
||||||
const subjectRows = await db
|
|
||||||
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
|
||||||
.from(classSubjectTeachers)
|
|
||||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
|
||||||
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
|
|
||||||
|
|
||||||
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
|
||||||
if (!preferred) throw new Error("Class already has assigned teachers")
|
|
||||||
const subjectRow = subjectRows.find((r) => r.name === preferred)
|
|
||||||
if (!subjectRow) throw new Error("Subject not found")
|
|
||||||
const sid = subjectRow.id
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(classSubjectTeachers)
|
|
||||||
.set({ teacherId: tid })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(classSubjectTeachers.classId, cls.id),
|
|
||||||
eq(classSubjectTeachers.subjectId, sid),
|
|
||||||
isNull(classSubjectTeachers.teacherId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const [assigned] = await db
|
|
||||||
.select({ id: classSubjectTeachers.classId })
|
|
||||||
.from(classSubjectTeachers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(classSubjectTeachers.classId, cls.id),
|
|
||||||
eq(classSubjectTeachers.subjectId, sid),
|
|
||||||
eq(classSubjectTeachers.teacherId, tid)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!assigned) throw new Error("Class already has assigned teachers")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消耗新表邀请码(旧表无计数,跳过)
|
|
||||||
if (result.codeId) {
|
|
||||||
await consumeInvitationCode(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cls.id
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
|
|
||||||
const [owned] = await db
|
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
|
||||||
|
|
||||||
const update: Partial<typeof classes.$inferSelect> = {}
|
|
||||||
if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null
|
|
||||||
if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null
|
|
||||||
if (typeof data.name === "string") update.name = data.name.trim()
|
|
||||||
if (typeof data.grade === "string") update.grade = data.grade.trim()
|
|
||||||
if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null
|
|
||||||
if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null
|
|
||||||
if (data.room !== undefined) update.room = data.room?.trim() || null
|
|
||||||
|
|
||||||
if (Object.keys(update).length === 0) return
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(classes)
|
|
||||||
.set(update)
|
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setClassSubjectTeachers(params: {
|
|
||||||
classId: string
|
|
||||||
assignments: Array<{ subject: ClassSubject; teacherId: string | null }>
|
|
||||||
}): Promise<void> {
|
|
||||||
const classId = params.classId.trim()
|
|
||||||
if (!classId) throw new Error("Missing class id")
|
|
||||||
|
|
||||||
const [existing] = await db.select({ id: classes.id }).from(classes).where(eq(classes.id, classId)).limit(1)
|
|
||||||
if (!existing) throw new Error("Class not found")
|
|
||||||
|
|
||||||
const teacherIds = params.assignments
|
|
||||||
.map((a) => a.teacherId)
|
|
||||||
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
|
||||||
|
|
||||||
if (teacherIds.length > 0) {
|
|
||||||
const rows = await db
|
|
||||||
.select({ id: users.id })
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
|
|
||||||
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
const teacherBySubject = new Map<ClassSubject, string | null>()
|
|
||||||
for (const a of params.assignments) {
|
|
||||||
if (!DEFAULT_CLASS_SUBJECTS.includes(a.subject)) continue
|
|
||||||
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map subject names to ids
|
|
||||||
const subjectRows = await db
|
|
||||||
.select({ id: subjects.id, name: subjects.name })
|
|
||||||
.from(subjects)
|
|
||||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
|
||||||
const idByName = new Map<ClassSubject, string>()
|
|
||||||
for (const r of subjectRows) {
|
|
||||||
const subject = toClassSubject(r.name)
|
|
||||||
if (subject) idByName.set(subject, r.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
|
||||||
const subjectId = idByName.get(name)
|
|
||||||
if (!subjectId) return []
|
|
||||||
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
|
|
||||||
})
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(classSubjectTeachers)
|
|
||||||
.values(values)
|
|
||||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTeacherClass(classId: string): Promise<void> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
|
|
||||||
const [owned] = await db
|
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(classes)
|
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enrollStudentByEmail(classId: string, email: string): Promise<void> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
const normalized = email.trim().toLowerCase()
|
|
||||||
if (!normalized) throw new Error("Student email is required")
|
|
||||||
|
|
||||||
const [owned] = await db
|
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
|
||||||
|
|
||||||
const [student] = await db
|
|
||||||
.select({ id: users.id })
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.email, normalized))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!student) throw new Error("Student not found")
|
|
||||||
const [studentRole] = await db
|
|
||||||
.select({ id: usersToRoles.userId })
|
|
||||||
.from(usersToRoles)
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
|
|
||||||
.limit(1)
|
|
||||||
if (!studentRole) throw new Error("User is not a student")
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(classEnrollments)
|
|
||||||
.values({ classId, studentId: student.id, status: "active" })
|
|
||||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setStudentEnrollmentStatus(classId: string, studentId: string, status: "active" | "inactive"): Promise<void> {
|
|
||||||
const teacherId = await getTeacherIdForMutations()
|
|
||||||
|
|
||||||
const [owned] = await db
|
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!owned) throw new Error("Class not found")
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ classId: classEnrollments.classId })
|
|
||||||
.from(classEnrollments)
|
|
||||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!existing) throw new Error("Enrollment not found")
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(classEnrollments)
|
|
||||||
.set({ status })
|
|
||||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export from split files for backward compatibility
|
// Re-export from split files for backward compatibility
|
||||||
export * from "./data-access-stats"
|
export * from "./data-access-stats"
|
||||||
export * from "./data-access-schedule"
|
export * from "./data-access-schedule"
|
||||||
export * from "./data-access-students"
|
export * from "./data-access-students"
|
||||||
export * from "./data-access-admin"
|
export * from "./data-access-admin"
|
||||||
|
export * from "./data-access-invitations"
|
||||||
|
export * from "./data-access-teacher"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DataScope resolver helpers (P1-5/P1-6 audit fix)
|
||||||
|
//
|
||||||
|
// P0-1 audit fix (2026-06-25): 下列 `getTeacherScopeData` / `getStudentScopeData`
|
||||||
|
// / `getGradeIdsForStudentIds` 三个函数原本在本文件与拆分文件中重复定义,
|
||||||
|
// ES 模块语义下本地定义会覆盖 `export *` 的同名导出,导致:
|
||||||
|
// - 通过 `@/modules/classes/data-access` 导入时得到本地版本
|
||||||
|
// - 通过 `@/modules/classes/data-access-teacher` / `-students` 导入时得到另一份实现
|
||||||
|
// 两份实现逻辑一致,但任何一方修改都不会同步,是高风险维护陷阱。
|
||||||
|
//
|
||||||
|
// 修复:删除本文件中的本地定义,统一从拆分文件通过 `export *` 暴露。
|
||||||
|
// 实际定义见:
|
||||||
|
// - getTeacherScopeData → data-access-teacher.ts
|
||||||
|
// - getStudentScopeData → data-access-students.ts
|
||||||
|
// - getGradeIdsForStudentIds → data-access-students.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
114
src/modules/classes/hooks/use-class-data.ts
Normal file
114
src/modules/classes/hooks/use-class-data.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import type { AdminClassListItem } from "../types"
|
||||||
|
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||||
|
import type { SubjectTeacherState } from "../components/class-form-utils"
|
||||||
|
|
||||||
|
export interface UseClassDataReturn {
|
||||||
|
createOpen: boolean
|
||||||
|
setCreateOpen: (open: boolean) => void
|
||||||
|
editItem: AdminClassListItem | null
|
||||||
|
setEditItem: (item: AdminClassListItem | null) => void
|
||||||
|
deleteItem: AdminClassListItem | null
|
||||||
|
setDeleteItem: (item: AdminClassListItem | null) => void
|
||||||
|
isWorking: boolean
|
||||||
|
setIsWorking: (v: boolean) => void
|
||||||
|
createTeacherId: string
|
||||||
|
setCreateTeacherId: (id: string) => void
|
||||||
|
createSchoolId: string
|
||||||
|
setCreateSchoolId: (id: string) => void
|
||||||
|
createGradeId: string
|
||||||
|
setCreateGradeId: (id: string) => void
|
||||||
|
editTeacherId: string
|
||||||
|
setEditTeacherId: (id: string) => void
|
||||||
|
editSchoolId: string
|
||||||
|
setEditSchoolId: (id: string) => void
|
||||||
|
editGradeId: string
|
||||||
|
setEditGradeId: (id: string) => void
|
||||||
|
editSubjectTeachers: SubjectTeacherState[]
|
||||||
|
setSubjectTeacher: (subject: string, teacherId: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级列表/弹窗共享状态:对话框开关、表单字段、提交中标记、重置逻辑。
|
||||||
|
* - createOpen 打开时按默认值重置 create* 字段(使用 derived state 模式避免 useEffect)
|
||||||
|
* - editItem 变化时按选中项重置 edit* 字段(使用 derived state 模式避免 useEffect)
|
||||||
|
*/
|
||||||
|
export function useClassData(config: {
|
||||||
|
defaultTeacherId: string
|
||||||
|
defaultSchoolId?: string
|
||||||
|
defaultGradeId?: string
|
||||||
|
}): UseClassDataReturn {
|
||||||
|
const { defaultTeacherId, defaultSchoolId = "", defaultGradeId = "" } = config
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
||||||
|
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
||||||
|
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
||||||
|
const [createSchoolId, setCreateSchoolId] = useState(defaultSchoolId)
|
||||||
|
const [createGradeId, setCreateGradeId] = useState(defaultGradeId)
|
||||||
|
const [editTeacherId, setEditTeacherId] = useState("")
|
||||||
|
const [editSchoolId, setEditSchoolId] = useState("")
|
||||||
|
const [editGradeId, setEditGradeId] = useState("")
|
||||||
|
const [editSubjectTeachers, setEditSubjectTeachers] = useState<SubjectTeacherState[]>([])
|
||||||
|
|
||||||
|
// Derived state pattern: reset create form when dialog opens
|
||||||
|
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||||
|
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||||
|
if (createOpen !== prevCreateOpen) {
|
||||||
|
setPrevCreateOpen(createOpen)
|
||||||
|
if (createOpen) {
|
||||||
|
setCreateTeacherId(defaultTeacherId)
|
||||||
|
setCreateSchoolId(defaultSchoolId)
|
||||||
|
setCreateGradeId(defaultGradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived state pattern: reset edit form when editItem changes
|
||||||
|
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||||
|
if (editItem !== prevEditItem) {
|
||||||
|
setPrevEditItem(editItem)
|
||||||
|
if (editItem) {
|
||||||
|
setEditTeacherId(editItem.teacher.id)
|
||||||
|
setEditSchoolId(editItem.schoolId ?? defaultSchoolId)
|
||||||
|
setEditGradeId(editItem.gradeId ?? defaultGradeId)
|
||||||
|
setEditSubjectTeachers(
|
||||||
|
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||||
|
subject: s,
|
||||||
|
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
||||||
|
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createOpen,
|
||||||
|
setCreateOpen,
|
||||||
|
editItem,
|
||||||
|
setEditItem,
|
||||||
|
deleteItem,
|
||||||
|
setDeleteItem,
|
||||||
|
isWorking,
|
||||||
|
setIsWorking,
|
||||||
|
createTeacherId,
|
||||||
|
setCreateTeacherId,
|
||||||
|
createSchoolId,
|
||||||
|
setCreateSchoolId,
|
||||||
|
createGradeId,
|
||||||
|
setCreateGradeId,
|
||||||
|
editTeacherId,
|
||||||
|
setEditTeacherId,
|
||||||
|
editSchoolId,
|
||||||
|
setEditSchoolId,
|
||||||
|
editGradeId,
|
||||||
|
setEditGradeId,
|
||||||
|
editSubjectTeachers,
|
||||||
|
setSubjectTeacher,
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/modules/classes/hooks/use-class-filters.ts
Normal file
31
src/modules/classes/hooks/use-class-filters.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
|
import type { ClassFormGrade } from "../components/class-form-utils"
|
||||||
|
|
||||||
|
export interface UseClassFiltersReturn {
|
||||||
|
createGrades: ClassFormGrade[]
|
||||||
|
editGrades: ClassFormGrade[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按学校筛选年级(admin 模式使用)。
|
||||||
|
* createGrades 按 createSchoolId 筛选,editGrades 按 editSchoolId 筛选。
|
||||||
|
* grade 模式不使用此 hook(managedGrades 直接展示,无需按学校过滤)。
|
||||||
|
*/
|
||||||
|
export function useClassFilters(
|
||||||
|
grades: ClassFormGrade[],
|
||||||
|
createSchoolId: string,
|
||||||
|
editSchoolId: string,
|
||||||
|
): UseClassFiltersReturn {
|
||||||
|
const createGrades = useMemo(
|
||||||
|
() => grades.filter((g) => g.schoolId === createSchoolId),
|
||||||
|
[grades, createSchoolId],
|
||||||
|
)
|
||||||
|
const editGrades = useMemo(
|
||||||
|
() => grades.filter((g) => g.schoolId === editSchoolId),
|
||||||
|
[grades, editSchoolId],
|
||||||
|
)
|
||||||
|
return { createGrades, editGrades }
|
||||||
|
}
|
||||||
@@ -5,12 +5,18 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
|||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { handleActionError } from "@/shared/lib/action-utils"
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateCoursePlanSchema,
|
CreateCoursePlanSchema,
|
||||||
UpdateCoursePlanSchema,
|
UpdateCoursePlanSchema,
|
||||||
CreateCoursePlanItemSchema,
|
CreateCoursePlanItemSchema,
|
||||||
UpdateCoursePlanItemSchema,
|
UpdateCoursePlanItemSchema,
|
||||||
|
GetCoursePlansParamsSchema,
|
||||||
|
GradeIdSchema,
|
||||||
|
ReorderItemsSchema,
|
||||||
|
BulkToggleSchema,
|
||||||
|
CopyPlanSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import {
|
import {
|
||||||
getCoursePlans,
|
getCoursePlans,
|
||||||
@@ -22,15 +28,78 @@ import {
|
|||||||
createCoursePlanItem,
|
createCoursePlanItem,
|
||||||
updateCoursePlanItem,
|
updateCoursePlanItem,
|
||||||
deleteCoursePlanItem,
|
deleteCoursePlanItem,
|
||||||
|
reorderCoursePlanItems,
|
||||||
|
bulkUpdateItemCompleted,
|
||||||
|
copyCoursePlanToClasses,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem, GradeCoursePlanProgressResult } from "./types"
|
import type {
|
||||||
|
CoursePlanWithItems,
|
||||||
|
GetCoursePlansParams,
|
||||||
|
CoursePlanListItem,
|
||||||
|
CoursePlanQueryScope,
|
||||||
|
GradeCoursePlanProgressResult,
|
||||||
|
ReorderCoursePlanItemInput,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
const revalidatePlanPaths = (id?: string) => {
|
/**
|
||||||
|
* 从 AuthContext 解析课程计划查询范围。
|
||||||
|
* - admin/教务主任 → 全局视图
|
||||||
|
* - teacher → 仅自己负责的计划(class_taught scope)
|
||||||
|
* - parent/student → 仅孩子所在班级的计划
|
||||||
|
*/
|
||||||
|
function resolveScope(
|
||||||
|
ctx: { userId: string; dataScope: DataScope; roles: string[] }
|
||||||
|
): CoursePlanQueryScope {
|
||||||
|
const isAdmin = ctx.roles.some(
|
||||||
|
(r) => r === "admin" || r === "grade_manager" || r === "academic_director"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAdmin || ctx.dataScope.type === "all") {
|
||||||
|
return { userId: ctx.userId, isAdmin: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教师:class_taught scope 携带 classIds,同时按 teacherId 过滤
|
||||||
|
if (ctx.dataScope.type === "class_taught") {
|
||||||
|
return {
|
||||||
|
userId: ctx.userId,
|
||||||
|
isAdmin: false,
|
||||||
|
teacherId: ctx.userId,
|
||||||
|
classIds: ctx.dataScope.classIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生:class_members scope 携带 classIds
|
||||||
|
if (ctx.dataScope.type === "class_members") {
|
||||||
|
return {
|
||||||
|
userId: ctx.userId,
|
||||||
|
isAdmin: false,
|
||||||
|
classIds: ctx.dataScope.classIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 家长:children scope 需通过孩子 ID 解析班级(调用方已预解析)
|
||||||
|
if (ctx.dataScope.type === "children") {
|
||||||
|
return {
|
||||||
|
userId: ctx.userId,
|
||||||
|
isAdmin: false,
|
||||||
|
classIds: [], // 家长视角需调用方补充 classIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grade_managed / owned:仅返回自己创建的
|
||||||
|
return { userId: ctx.userId, isAdmin: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const revalidatePlanPaths = (id?: string): void => {
|
||||||
revalidatePath("/admin/course-plans")
|
revalidatePath("/admin/course-plans")
|
||||||
revalidatePath("/teacher/course-plans")
|
revalidatePath("/teacher/course-plans")
|
||||||
|
revalidatePath("/parent/course-plans")
|
||||||
|
revalidatePath("/student/course-plans")
|
||||||
if (id) {
|
if (id) {
|
||||||
revalidatePath(`/admin/course-plans/${id}`)
|
revalidatePath(`/admin/course-plans/${id}`)
|
||||||
revalidatePath(`/teacher/course-plans/${id}`)
|
revalidatePath(`/teacher/course-plans/${id}`)
|
||||||
|
revalidatePath(`/parent/course-plans/${id}`)
|
||||||
|
revalidatePath(`/student/course-plans/${id}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +147,11 @@ export async function updateCoursePlanAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
const existing = await getCoursePlanById(id)
|
// 权限二次校验:非 admin 只能修改自己负责的计划
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const existing = await getCoursePlanById(id, scope)
|
||||||
if (!existing) return { success: false, message: "Course plan not found" }
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
const parsed = UpdateCoursePlanSchema.safeParse({
|
const parsed = UpdateCoursePlanSchema.safeParse({
|
||||||
@@ -119,9 +190,11 @@ export async function deleteCoursePlanAction(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
const existing = await getCoursePlanById(id)
|
// 权限二次校验:非 admin 只能删除自己负责的计划
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const existing = await getCoursePlanById(id, scope)
|
||||||
if (!existing) return { success: false, message: "Course plan not found" }
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
await deleteCoursePlan(id)
|
await deleteCoursePlan(id)
|
||||||
@@ -136,8 +209,25 @@ export async function getCoursePlansAction(
|
|||||||
params?: GetCoursePlansParams
|
params?: GetCoursePlansParams
|
||||||
): Promise<ActionState<CoursePlanListItem[]>> {
|
): Promise<ActionState<CoursePlanListItem[]>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
const data = await getCoursePlans(params)
|
|
||||||
|
// P1-6:Zod 验证入参(避免 union 类型问题,提前返回)
|
||||||
|
if (params) {
|
||||||
|
const parsed = GetCoursePlansParamsSchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid params",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const data = await getCoursePlans(parsed.data, scope)
|
||||||
|
return { success: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const data = await getCoursePlans(undefined, scope)
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
@@ -148,8 +238,9 @@ export async function getCoursePlanAction(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<ActionState<CoursePlanWithItems>> {
|
): Promise<ActionState<CoursePlanWithItems>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
const data = await getCoursePlanById(id)
|
const scope = resolveScope(ctx)
|
||||||
|
const data = await getCoursePlanById(id, scope)
|
||||||
if (!data) return { success: false, message: "Course plan not found" }
|
if (!data) return { success: false, message: "Course plan not found" }
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -162,7 +253,7 @@ export async function createCoursePlanItemAction(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
const parsed = CreateCoursePlanItemSchema.safeParse({
|
const parsed = CreateCoursePlanItemSchema.safeParse({
|
||||||
planId: formData.get("planId"),
|
planId: formData.get("planId"),
|
||||||
@@ -182,6 +273,11 @@ export async function createCoursePlanItemAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 权限二次校验:非 admin 只能操作自己负责的计划
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const existing = await getCoursePlanById(parsed.data.planId, scope)
|
||||||
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
const itemId = await createCoursePlanItem(parsed.data)
|
const itemId = await createCoursePlanItem(parsed.data)
|
||||||
revalidatePlanPaths(parsed.data.planId)
|
revalidatePlanPaths(parsed.data.planId)
|
||||||
return { success: true, message: "Week plan added", data: itemId }
|
return { success: true, message: "Week plan added", data: itemId }
|
||||||
@@ -263,6 +359,108 @@ export async function toggleCoursePlanItemCompletedAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽排序周计划条目(P1-7)。
|
||||||
|
*/
|
||||||
|
export async function reorderCoursePlanItemsAction(
|
||||||
|
input: { planId: string; items: ReorderCoursePlanItemInput[] }
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const parsed = ReorderItemsSchema.safeParse(input)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: "Invalid params", errors: parsed.error.flatten().fieldErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限二次校验:非 admin 只能操作自己负责的计划
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const existing = await getCoursePlanById(parsed.data.planId, scope)
|
||||||
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
|
await reorderCoursePlanItems(parsed.data.planId, parsed.data.items)
|
||||||
|
revalidatePlanPaths(parsed.data.planId)
|
||||||
|
return { success: true, message: "Order saved", data: parsed.data.planId }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量标记周计划条目完成状态(P2-4)。
|
||||||
|
*/
|
||||||
|
export async function bulkToggleItemsAction(
|
||||||
|
itemIds: string[],
|
||||||
|
completed: boolean
|
||||||
|
): Promise<ActionState<number>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const parsed = BulkToggleSchema.safeParse({ itemIds, completed })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: "Invalid params", errors: parsed.error.flatten().fieldErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await bulkUpdateItemCompleted(parsed.data.itemIds, parsed.data.completed)
|
||||||
|
revalidatePlanPaths()
|
||||||
|
return { success: true, message: "Bulk updated", data: count }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制课程计划到其他班级(P2-4)。
|
||||||
|
*/
|
||||||
|
export async function copyCoursePlanAction(
|
||||||
|
sourcePlanId: string,
|
||||||
|
targetClassIds: string[]
|
||||||
|
): Promise<ActionState<string[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const parsed = CopyPlanSchema.safeParse({ sourcePlanId, targetClassIds })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: "Invalid params", errors: parsed.error.flatten().fieldErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限二次校验:非 admin 只能复制自己负责的计划
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const existing = await getCoursePlanById(parsed.data.sourcePlanId, scope)
|
||||||
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
|
const ids = await copyCoursePlanToClasses(parsed.data.sourcePlanId, parsed.data.targetClassIds)
|
||||||
|
revalidatePlanPaths()
|
||||||
|
return { success: true, message: "Copied", data: ids }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取课程计划模板候选列表(P2-5 模板库)。
|
||||||
|
*
|
||||||
|
* 复用现有计划作为模板:返回当前用户可见范围内的计划(含 items 数量),
|
||||||
|
* 供 "从模板创建" 选择器使用。不新增 DB 表,避免 schema 迁移。
|
||||||
|
*
|
||||||
|
* @param subjectId 可选,按学科过滤
|
||||||
|
*/
|
||||||
|
export async function getTemplateCandidatesAction(
|
||||||
|
subjectId?: string
|
||||||
|
): Promise<ActionState<CoursePlanListItem[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
|
const scope = resolveScope(ctx)
|
||||||
|
const data = await getCoursePlans(
|
||||||
|
subjectId ? { subjectId } : undefined,
|
||||||
|
scope
|
||||||
|
)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。
|
* 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。
|
||||||
*/
|
*/
|
||||||
@@ -272,13 +470,27 @@ export async function getGradeCoursePlanProgressAction(
|
|||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
|
|
||||||
if (!gradeId || gradeId.trim().length === 0) {
|
const parsed = GradeIdSchema.safeParse({ gradeId })
|
||||||
|
if (!parsed.success) {
|
||||||
return { success: false, message: "Invalid grade id" }
|
return { success: false, message: "Invalid grade id" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getGradeCoursePlanProgress({ gradeId })
|
const data = await getGradeCoursePlanProgress({ gradeId: parsed.data.gradeId })
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 监控埋点接口(P2-8)────────────────────────────────────
|
||||||
|
// 预留埋点接口,供前端调用以记录关键操作。
|
||||||
|
// 当前为空实现,后续接入监控 SDK 时只需修改此函数。
|
||||||
|
export function trackCoursePlanEvent(
|
||||||
|
event: string,
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
// 预留:接入监控 SDK 后实现
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.debug(`[course-plans] ${event}`, properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
219
src/modules/course-plans/components/course-plan-calendar.tsx
Normal file
219
src/modules/course-plans/components/course-plan-calendar.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import type { CoursePlanWithItems } from "../types"
|
||||||
|
import {
|
||||||
|
planToCalendarEvents,
|
||||||
|
buildMonthGrid,
|
||||||
|
eventsOnDay,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
isSameDay,
|
||||||
|
addMonths,
|
||||||
|
type CalendarEvent,
|
||||||
|
} from "../lib/calendar-utils"
|
||||||
|
|
||||||
|
interface CoursePlanCalendarProps {
|
||||||
|
plan: CoursePlanWithItems
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程计划月历视图(P2-7)。
|
||||||
|
*
|
||||||
|
* - 通过纯函数 `planToCalendarEvents` 将周计划映射到日期范围
|
||||||
|
* - 月历网格采用 6×7 布局,周一为每周起始
|
||||||
|
* - 支持上一月/下一月/今天导航
|
||||||
|
* - 单元格内显示当日事件,按完成状态着色
|
||||||
|
* - 无 `plan.startDate` 时显示空状态提示
|
||||||
|
*
|
||||||
|
* 可访问性:
|
||||||
|
* - 导航按钮带 `aria-label`
|
||||||
|
* - 日期单元格带 `role="gridcell"` 与 `aria-label`
|
||||||
|
* - 今日单元格带 `aria-current="date"`
|
||||||
|
*/
|
||||||
|
export function CoursePlanCalendar({ plan }: CoursePlanCalendarProps): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
|
const [cursor, setCursor] = useState<Date>(() => new Date())
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = useMemo(
|
||||||
|
() => planToCalendarEvents(plan),
|
||||||
|
[plan],
|
||||||
|
)
|
||||||
|
|
||||||
|
const grid: Date[] = useMemo(() => buildMonthGrid(cursor), [cursor])
|
||||||
|
const monthStart = useMemo(() => startOfMonth(cursor), [cursor])
|
||||||
|
const monthEnd = useMemo(() => endOfMonth(cursor), [cursor])
|
||||||
|
const visibleEvents = useMemo(
|
||||||
|
() => events.filter((e) => {
|
||||||
|
const start = new Date(e.startDate)
|
||||||
|
const end = new Date(e.endDate)
|
||||||
|
return start <= monthEnd && end >= monthStart
|
||||||
|
}),
|
||||||
|
[events, monthStart, monthEnd],
|
||||||
|
)
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const weekLabels = t.raw("calendar.weekShort") as unknown as string[]
|
||||||
|
|
||||||
|
const handlePrev = (): void => setCursor((prev) => addMonths(prev, -1))
|
||||||
|
const handleNext = (): void => setCursor((prev) => addMonths(prev, 1))
|
||||||
|
const handleToday = (): void => setCursor(new Date())
|
||||||
|
|
||||||
|
if (!plan.startDate) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 text-center"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("calendar.noStartDate")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 导航条 */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t("calendar.monthTitle", {
|
||||||
|
year: cursor.getFullYear(),
|
||||||
|
month: cursor.getMonth() + 1,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePrev}
|
||||||
|
aria-label={t("calendar.prevMonth")}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleToday}>
|
||||||
|
{t("calendar.today")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleNext}
|
||||||
|
aria-label={t("calendar.nextMonth")}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 周首行 */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-7 gap-1 text-center text-xs font-medium text-muted-foreground"
|
||||||
|
role="row"
|
||||||
|
>
|
||||||
|
{weekLabels.map((label) => (
|
||||||
|
<div key={label} className="py-1" role="columnheader">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日期网格 */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-7 gap-1"
|
||||||
|
role="grid"
|
||||||
|
aria-label={t("calendar.title")}
|
||||||
|
>
|
||||||
|
{grid.map((day, index) => {
|
||||||
|
const dayEvents = eventsOnDay(visibleEvents, day)
|
||||||
|
const inMonth = day.getMonth() === cursor.getMonth()
|
||||||
|
const isToday = isSameDay(day, today)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
role="gridcell"
|
||||||
|
aria-label={day.toDateString()}
|
||||||
|
aria-current={isToday ? "date" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[80px] rounded-md border p-1 text-left",
|
||||||
|
inMonth ? "bg-card" : "bg-muted/30",
|
||||||
|
isToday && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-1 text-xs font-medium",
|
||||||
|
inMonth ? "text-foreground" : "text-muted-foreground/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{dayEvents.slice(0, 2).map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className={cn(
|
||||||
|
"truncate rounded px-1 py-0.5 text-[10px] leading-tight",
|
||||||
|
event.isCompleted
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={`${t("calendar.week", { week: event.week })} · ${event.title}`}
|
||||||
|
>
|
||||||
|
{t("calendar.week", { week: event.week })}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayEvents.length > 2 ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
+{dayEvents.length - 2}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当月事件列表 */}
|
||||||
|
{visibleEvents.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold">{t("calendar.title")}</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{visibleEvents.map((event) => (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-md border p-2 text-xs"
|
||||||
|
>
|
||||||
|
<Badge variant={event.isCompleted ? "default" : "secondary"}>
|
||||||
|
{event.isCompleted ? t("calendar.completed") : t("calendar.pending")}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("calendar.week", { week: event.week })}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{event.title}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{event.startDate} ~ {event.endDate}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("calendar.hours", { hours: event.hours })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("calendar.noPlans")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
|
import { ArrowLeft, Pencil, Plus, Trash2, Download } from "lucide-react"
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -12,37 +29,48 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
|||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
import { usePermission } from "@/shared/hooks/use-permission"
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import { CoursePlanProgress } from "./course-plan-progress"
|
import { CoursePlanProgress } from "./course-plan-progress"
|
||||||
import { CoursePlanItemEditor } from "./course-plan-item-editor"
|
import { CoursePlanItemEditor } from "./course-plan-item-editor"
|
||||||
import { deleteCoursePlanAction } from "../actions"
|
import { SortableWeekRow } from "./sortable-week-row"
|
||||||
import type { CoursePlanWithItems, CoursePlanStatus } from "../types"
|
import { CoursePlanCalendar } from "./course-plan-calendar"
|
||||||
|
import {
|
||||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
deleteCoursePlanAction,
|
||||||
planning: "规划中",
|
bulkToggleItemsAction,
|
||||||
active: "进行中",
|
reorderCoursePlanItemsAction,
|
||||||
completed: "已完成",
|
trackCoursePlanEvent,
|
||||||
paused: "已暂停",
|
} from "../actions"
|
||||||
}
|
import { exportCoursePlanReport } from "../lib/export-utils"
|
||||||
|
import type { CoursePlanWithItems, ReorderCoursePlanItemInput } from "../types"
|
||||||
|
|
||||||
export function CoursePlanDetail({
|
export function CoursePlanDetail({
|
||||||
plan,
|
plan,
|
||||||
editHref,
|
editHref,
|
||||||
backHref,
|
backHref,
|
||||||
|
successHref,
|
||||||
|
textbooksHref,
|
||||||
|
homeworkHref,
|
||||||
}: {
|
}: {
|
||||||
plan: CoursePlanWithItems
|
plan: CoursePlanWithItems
|
||||||
editHref?: string
|
editHref?: string
|
||||||
backHref?: string
|
backHref?: string
|
||||||
}) {
|
/** 删除成功后的跳转路径(替代 URL 路径推断) */
|
||||||
|
successHref?: string
|
||||||
|
/** 教材列表页地址(按角色不同);提供时周计划章节文本渲染为可跳转链接(P2-3 数据联动) */
|
||||||
|
textbooksHref?: string
|
||||||
|
/** 作业列表页地址(按角色不同);提供时周计划行尾显示作业跳转按钮(P2-3 数据联动) */
|
||||||
|
homeworkHref?: string
|
||||||
|
}): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
const canManage = hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
const canManage = hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
@@ -51,39 +79,134 @@ export function CoursePlanDetail({
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editingItem, setEditingItem] = useState<CoursePlanWithItems["items"][number] | undefined>()
|
const [editingItem, setEditingItem] = useState<CoursePlanWithItems["items"][number] | undefined>()
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const completedItems = plan.items.filter((i) => i.isCompleted).length
|
const completedItems = plan.items.filter((i) => i.isCompleted).length
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await deleteCoursePlanAction(plan.id)
|
const res = await deleteCoursePlanAction(plan.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(t("toast.deleted"))
|
||||||
const base = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
trackCoursePlanEvent("plan_deleted", { planId: plan.id })
|
||||||
router.push(base)
|
router.push(successHref ?? "/admin/course-plans")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "删除失败")
|
toast.error(res.message || t("toast.deleteFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("删除失败")
|
toast.error(t("toast.deleteFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
setDeleteOpen(false)
|
setDeleteOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreateEditor = () => {
|
const openCreateEditor = (): void => {
|
||||||
setEditingItem(undefined)
|
setEditingItem(undefined)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditEditor = (item: CoursePlanWithItems["items"][number]) => {
|
const openEditEditor = (item: CoursePlanWithItems["items"][number]): void => {
|
||||||
setEditingItem(item)
|
setEditingItem(item)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSelect = (id: string): void => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkComplete = async (): Promise<void> => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await bulkToggleItemsAction(Array.from(selectedIds), true)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("toast.bulkMarked", { count: res.data ?? 0 }))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(t("toast.bulkFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("toast.bulkFailed"))
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽排序传感器:指针 + 键盘(a11y)
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleReorder = async (event: DragEndEvent): Promise<void> => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = plan.items.findIndex((i) => i.id === active.id)
|
||||||
|
const newIndex = plan.items.findIndex((i) => i.id === over.id)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
// 乐观更新:先计算新顺序(无需本地 state,dnd-kit transform 已处理拖拽视觉)
|
||||||
|
const reordered = arrayMove(plan.items, oldIndex, newIndex)
|
||||||
|
const items: ReorderCoursePlanItemInput[] = reordered.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
week: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await reorderCoursePlanItemsAction({ planId: plan.id, items })
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("detail.reorderSaved"))
|
||||||
|
trackCoursePlanEvent("items_reordered", { planId: plan.id, count: items.length })
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(t("detail.reorderFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("detail.reorderFailed"))
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = (): void => {
|
||||||
|
try {
|
||||||
|
const filename = t("export.filename", {
|
||||||
|
subject: plan.subjectName ?? "unknown",
|
||||||
|
className: plan.className ?? "no-class",
|
||||||
|
})
|
||||||
|
exportCoursePlanReport(
|
||||||
|
plan,
|
||||||
|
{
|
||||||
|
week: t("detail.week"),
|
||||||
|
topic: t("detail.topic"),
|
||||||
|
content: t("export.content"),
|
||||||
|
hours: t("detail.hours"),
|
||||||
|
textbookChapter: t("detail.chapter"),
|
||||||
|
status: t("detail.statusCol"),
|
||||||
|
notes: t("export.notes"),
|
||||||
|
completed: t("detail.completed"),
|
||||||
|
pending: t("detail.pending"),
|
||||||
|
},
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
toast.success(t("export.exported"))
|
||||||
|
trackCoursePlanEvent("plan_exported", { planId: plan.id, format: "csv" })
|
||||||
|
} catch {
|
||||||
|
toast.error(t("export.exportFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -91,136 +214,162 @@ export function CoursePlanDetail({
|
|||||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||||
<Link href={backHref}>
|
<Link href={backHref}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
返回课程计划列表
|
{t("detail.back")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">课程计划</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("detail.heading")}</h2>
|
||||||
{canManage ? (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Button onClick={handleExport} variant="outline" disabled={plan.items.length === 0}>
|
||||||
{editHref ? (
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Button asChild variant="outline">
|
{t("export.csv")}
|
||||||
<Link href={editHref}>
|
</Button>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
{canManage ? (
|
||||||
编辑
|
<>
|
||||||
</Link>
|
{editHref ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={editHref}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
{t("detail.edit")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("detail.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</>
|
||||||
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
|
) : null}
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
</div>
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="space-y-2">
|
<CardHeader className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">{plan.className ?? "无班级"}</Badge>
|
<Badge variant="outline">{plan.className ?? t("detail.noClass")}</Badge>
|
||||||
<Badge variant="outline">{plan.subjectName ?? "未知学科"}</Badge>
|
<Badge variant="outline">{plan.subjectName ?? t("detail.unknownSubject")}</Badge>
|
||||||
<Badge>{STATUS_LABEL[plan.status]}</Badge>
|
<Badge>{t(`status.${plan.status}`)}</Badge>
|
||||||
<Badge variant="outline">第 {plan.semester} 学期</Badge>
|
<Badge variant="outline">{t("detail.semester", { semester: plan.semester })}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">
|
<CardTitle className="text-xl">
|
||||||
{plan.subjectName ?? "课程计划"} — {plan.className ?? "无班级"}
|
{plan.subjectName ?? t("detail.unknownSubjectHeading")} — {plan.className ?? t("detail.noClass")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>教师:{plan.teacherName ?? "未分配"}</span>
|
<span>
|
||||||
<span>· 创建于 {formatDate(plan.createdAt)}</span>
|
{plan.teacherName
|
||||||
{plan.startDate ? <span>· 开始 {formatDate(plan.startDate)}</span> : null}
|
? t("detail.teacher", { name: plan.teacherName })
|
||||||
{plan.endDate ? <span>· 结束 {formatDate(plan.endDate)}</span> : null}
|
: t("detail.unassigned")}
|
||||||
|
</span>
|
||||||
|
<span>· {t("detail.created", { date: formatDate(plan.createdAt) })}</span>
|
||||||
|
{plan.startDate ? <span>· {t("detail.startDate", { date: formatDate(plan.startDate) })}</span> : null}
|
||||||
|
{plan.endDate ? <span>· {t("detail.endDate", { date: formatDate(plan.endDate) })}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<CoursePlanProgress
|
<SectionErrorBoundary namespace="coursePlans">
|
||||||
completedHours={plan.completedHours}
|
<CoursePlanProgress
|
||||||
totalHours={plan.totalHours}
|
completedHours={plan.completedHours}
|
||||||
completedItems={completedItems}
|
totalHours={plan.totalHours}
|
||||||
totalItems={plan.items.length}
|
completedItems={completedItems}
|
||||||
/>
|
totalItems={plan.items.length}
|
||||||
|
/>
|
||||||
|
</SectionErrorBoundary>
|
||||||
{plan.syllabus ? (
|
{plan.syllabus ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-sm font-semibold">教学大纲</h4>
|
<h4 className="text-sm font-semibold">{t("detail.syllabus")}</h4>
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.syllabus}</p>
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.syllabus}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{plan.objectives ? (
|
{plan.objectives ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-sm font-semibold">教学目标</h4>
|
<h4 className="text-sm font-semibold">{t("detail.objectives")}</h4>
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.objectives}</p>
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.objectives}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<SectionErrorBoundary namespace="coursePlans">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<Card>
|
||||||
<CardTitle>周计划</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
{canManage ? (
|
<CardTitle>{t("detail.weekPlans")}</CardTitle>
|
||||||
<Button onClick={openCreateEditor} size="sm">
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
{canManage && selectedIds.size > 0 ? (
|
||||||
添加周计划
|
<Button onClick={handleBulkComplete} size="sm" disabled={isWorking}>
|
||||||
</Button>
|
{t("bulk.markComplete")}
|
||||||
) : null}
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManage ? (
|
||||||
|
<Button onClick={openCreateEditor} size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("detail.addWeekPlan")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{plan.items.length === 0 ? (
|
{plan.items.length === 0 ? (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
暂无周计划。{canManage ? "点击「添加周计划」创建第一条。" : ""}
|
{t("detail.emptyWeekPlans")}
|
||||||
</p>
|
{canManage ? t("detail.emptyWeekPlansCta") : ""}
|
||||||
) : (
|
</p>
|
||||||
<Table>
|
) : (
|
||||||
<TableHeader>
|
<DndContext
|
||||||
<TableRow>
|
id="course-plan-weeks-dnd"
|
||||||
<TableHead className="w-16">周次</TableHead>
|
sensors={sensors}
|
||||||
<TableHead>主题</TableHead>
|
collisionDetection={closestCenter}
|
||||||
<TableHead className="w-20">课时</TableHead>
|
onDragEnd={handleReorder}
|
||||||
<TableHead className="w-32">章节</TableHead>
|
>
|
||||||
<TableHead className="w-28">状态</TableHead>
|
<Table>
|
||||||
</TableRow>
|
<TableHeader>
|
||||||
</TableHeader>
|
<TableRow>
|
||||||
<TableBody>
|
{canManage ? <TableHead className="w-8" /> : null}
|
||||||
{plan.items.map((item) => (
|
{canManage ? <TableHead className="w-8" /> : null}
|
||||||
<TableRow
|
<TableHead className="w-16">{t("detail.week")}</TableHead>
|
||||||
key={item.id}
|
<TableHead>{t("detail.topic")}</TableHead>
|
||||||
className={canManage ? "cursor-pointer" : ""}
|
<TableHead className="w-20">{t("detail.hours")}</TableHead>
|
||||||
onClick={canManage ? () => openEditEditor(item) : undefined}
|
<TableHead className="w-32">{t("detail.chapter")}</TableHead>
|
||||||
|
<TableHead className="w-28">{t("detail.statusCol")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<SortableContext
|
||||||
|
items={plan.items.map((i) => i.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium">{item.week}</TableCell>
|
<TableBody>
|
||||||
<TableCell>
|
{plan.items.map((item) => (
|
||||||
<div className="space-y-1">
|
<SortableWeekRow
|
||||||
<p className="font-medium">{item.topic}</p>
|
key={item.id}
|
||||||
{item.content ? (
|
item={item}
|
||||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
canManage={canManage}
|
||||||
{item.content}
|
isSelected={selectedIds.has(item.id)}
|
||||||
</p>
|
onToggleSelect={toggleSelect}
|
||||||
) : null}
|
onEdit={openEditEditor}
|
||||||
{item.notes ? (
|
textbooksHref={textbooksHref}
|
||||||
<p className="text-xs text-muted-foreground italic">
|
homeworkHref={homeworkHref}
|
||||||
备注:{item.notes}
|
/>
|
||||||
</p>
|
))}
|
||||||
) : null}
|
</TableBody>
|
||||||
</div>
|
</SortableContext>
|
||||||
</TableCell>
|
</Table>
|
||||||
<TableCell>{item.hours}</TableCell>
|
</DndContext>
|
||||||
<TableCell className="text-muted-foreground">
|
)}
|
||||||
{item.textbookChapter ?? "—"}
|
</CardContent>
|
||||||
</TableCell>
|
</Card>
|
||||||
<TableCell>
|
</SectionErrorBoundary>
|
||||||
<Badge variant={item.isCompleted ? "default" : "secondary"}>
|
|
||||||
{item.isCompleted ? "已完成" : "待完成"}
|
<SectionErrorBoundary namespace="coursePlans">
|
||||||
</Badge>
|
<Card>
|
||||||
</TableCell>
|
<CardHeader>
|
||||||
</TableRow>
|
<CardTitle>{t("calendar.title")}</CardTitle>
|
||||||
))}
|
</CardHeader>
|
||||||
</TableBody>
|
<CardContent>
|
||||||
</Table>
|
<CoursePlanCalendar plan={plan} />
|
||||||
)}
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</SectionErrorBoundary>
|
||||||
|
|
||||||
<CoursePlanItemEditor
|
<CoursePlanItemEditor
|
||||||
planId={plan.id}
|
planId={plan.id}
|
||||||
@@ -233,8 +382,8 @@ export function CoursePlanDetail({
|
|||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
onOpenChange={setDeleteOpen}
|
onOpenChange={setDeleteOpen}
|
||||||
title="删除课程计划"
|
title={t("detail.deleteTitle")}
|
||||||
description="此操作将永久删除该课程计划及其所有周计划。"
|
description={t("detail.deleteDescription")}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
isWorking={isWorking}
|
isWorking={isWorking}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -16,8 +18,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
|
import { FileText } from "lucide-react"
|
||||||
|
|
||||||
import { createCoursePlanAction, updateCoursePlanAction } from "../actions"
|
import { createCoursePlanAction, updateCoursePlanAction } from "../actions"
|
||||||
|
import { TemplatePickerDialog } from "./template-picker-dialog"
|
||||||
|
import { isCoursePlanSemester, isCoursePlanStatus } from "../types"
|
||||||
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||||
|
|
||||||
type Mode = "create" | "edit"
|
type Mode = "create" | "edit"
|
||||||
@@ -27,6 +32,8 @@ interface Option {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_VALUES: CoursePlanStatus[] = ["planning", "active", "completed", "paused"]
|
||||||
|
|
||||||
export function CoursePlanForm({
|
export function CoursePlanForm({
|
||||||
mode,
|
mode,
|
||||||
plan,
|
plan,
|
||||||
@@ -35,6 +42,7 @@ export function CoursePlanForm({
|
|||||||
teachers = [],
|
teachers = [],
|
||||||
academicYears = [],
|
academicYears = [],
|
||||||
backHref,
|
backHref,
|
||||||
|
successHref,
|
||||||
}: {
|
}: {
|
||||||
mode: Mode
|
mode: Mode
|
||||||
plan?: CoursePlanListItem
|
plan?: CoursePlanListItem
|
||||||
@@ -43,7 +51,10 @@ export function CoursePlanForm({
|
|||||||
teachers?: Option[]
|
teachers?: Option[]
|
||||||
academicYears?: Option[]
|
academicYears?: Option[]
|
||||||
backHref?: string
|
backHref?: string
|
||||||
}) {
|
/** 成功后的跳转路径(替代 URL 路径推断) */
|
||||||
|
successHref?: string
|
||||||
|
}): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
@@ -51,10 +62,19 @@ export function CoursePlanForm({
|
|||||||
const [subjectId, setSubjectId] = useState(plan?.subjectId ?? "")
|
const [subjectId, setSubjectId] = useState(plan?.subjectId ?? "")
|
||||||
const [teacherId, setTeacherId] = useState(plan?.teacherId ?? "")
|
const [teacherId, setTeacherId] = useState(plan?.teacherId ?? "")
|
||||||
const [semester, setSemester] = useState(plan?.semester ?? "1")
|
const [semester, setSemester] = useState(plan?.semester ?? "1")
|
||||||
const [status, setStatus] = useState(plan?.status ?? "planning")
|
const [status, setStatus] = useState<CoursePlanStatus>(plan?.status ?? "planning")
|
||||||
const [academicYearId, setAcademicYearId] = useState(plan?.academicYearId ?? "")
|
const [academicYearId, setAcademicYearId] = useState(plan?.academicYearId ?? "")
|
||||||
|
const [templateOpen, setTemplateOpen] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSemesterChange = (v: string): void => {
|
||||||
|
if (isCoursePlanSemester(v)) setSemester(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = (v: string): void => {
|
||||||
|
if (isCoursePlanStatus(v)) setStatus(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
formData.set("classId", classId)
|
formData.set("classId", classId)
|
||||||
@@ -72,20 +92,19 @@ export function CoursePlanForm({
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
toast.error("Invalid form state")
|
toast.error(t("form.invalidState"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
router.push(successHref ?? "/admin/course-plans")
|
||||||
router.push(redirectBase)
|
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to save course plan")
|
toast.error(res.message || t("form.saveFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to save course plan")
|
toast.error(t("form.saveFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
@@ -95,17 +114,17 @@ export function CoursePlanForm({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{mode === "create" ? "New Course Plan" : "Edit Course Plan"}
|
{mode === "create" ? t("form.new") : t("form.edit")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action={handleSubmit} className="space-y-6">
|
<form action={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Class</Label>
|
<Label>{t("form.class")}</Label>
|
||||||
<Select value={classId} onValueChange={setClassId}>
|
<Select value={classId} onValueChange={setClassId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a class" />
|
<SelectValue placeholder={t("form.selectClass")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
@@ -119,10 +138,10 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Subject</Label>
|
<Label>{t("form.subject")}</Label>
|
||||||
<Select value={subjectId} onValueChange={setSubjectId}>
|
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a subject" />
|
<SelectValue placeholder={t("form.selectSubject")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{subjects.map((s) => (
|
{subjects.map((s) => (
|
||||||
@@ -136,15 +155,15 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Teacher</Label>
|
<Label>{t("form.teacher")}</Label>
|
||||||
<Select value={teacherId} onValueChange={setTeacherId}>
|
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a teacher" />
|
<SelectValue placeholder={t("form.selectTeacher")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{teachers.map((t) => (
|
{teachers.map((teacher) => (
|
||||||
<SelectItem key={t.id} value={t.id}>
|
<SelectItem key={teacher.id} value={teacher.id}>
|
||||||
{t.name}
|
{teacher.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -153,10 +172,10 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Academic Year</Label>
|
<Label>{t("form.academicYear")}</Label>
|
||||||
<Select value={academicYearId} onValueChange={setAcademicYearId}>
|
<Select value={academicYearId} onValueChange={setAcademicYearId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Optional" />
|
<SelectValue placeholder={t("form.optional")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{academicYears.map((y) => (
|
{academicYears.map((y) => (
|
||||||
@@ -170,37 +189,38 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Semester</Label>
|
<Label>{t("form.semester")}</Label>
|
||||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
<Select value={semester} onValueChange={handleSemesterChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select semester" />
|
<SelectValue placeholder={t("form.semester")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">Semester 1</SelectItem>
|
<SelectItem value="1">{t("form.semester1")}</SelectItem>
|
||||||
<SelectItem value="2">Semester 2</SelectItem>
|
<SelectItem value="2">{t("form.semester2")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input type="hidden" name="semester" value={semester} />
|
<input type="hidden" name="semester" value={semester} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status</Label>
|
<Label>{t("form.status")}</Label>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as CoursePlanStatus)}>
|
<Select value={status} onValueChange={handleStatusChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder={t("form.selectStatus")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="planning">Planning</SelectItem>
|
{STATUS_VALUES.map((s) => (
|
||||||
<SelectItem value="active">Active</SelectItem>
|
<SelectItem key={s} value={s}>
|
||||||
<SelectItem value="completed">Completed</SelectItem>
|
{t(`status.${s}`)}
|
||||||
<SelectItem value="paused">Paused</SelectItem>
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input type="hidden" name="status" value={status} />
|
<input type="hidden" name="status" value={status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="totalHours">Total Hours</Label>
|
<Label htmlFor="totalHours">{t("form.totalHours")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="totalHours"
|
id="totalHours"
|
||||||
name="totalHours"
|
name="totalHours"
|
||||||
@@ -211,7 +231,7 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="weeklyHours">Weekly Hours</Label>
|
<Label htmlFor="weeklyHours">{t("form.weeklyHours")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="weeklyHours"
|
id="weeklyHours"
|
||||||
name="weeklyHours"
|
name="weeklyHours"
|
||||||
@@ -222,7 +242,7 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="startDate">Start Date</Label>
|
<Label htmlFor="startDate">{t("form.startDate")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="startDate"
|
id="startDate"
|
||||||
name="startDate"
|
name="startDate"
|
||||||
@@ -232,7 +252,7 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="endDate">End Date</Label>
|
<Label htmlFor="endDate">{t("form.endDate")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="endDate"
|
id="endDate"
|
||||||
name="endDate"
|
name="endDate"
|
||||||
@@ -243,38 +263,59 @@ export function CoursePlanForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="syllabus">Syllabus</Label>
|
<Label htmlFor="syllabus">{t("form.syllabus")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="syllabus"
|
id="syllabus"
|
||||||
name="syllabus"
|
name="syllabus"
|
||||||
placeholder="Teaching syllabus..."
|
placeholder={t("form.syllabusPlaceholder")}
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px]"
|
||||||
defaultValue={plan?.syllabus ?? ""}
|
defaultValue={plan?.syllabus ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="objectives">Objectives</Label>
|
<Label htmlFor="objectives">{t("form.objectives")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="objectives"
|
id="objectives"
|
||||||
name="objectives"
|
name="objectives"
|
||||||
placeholder="Teaching objectives..."
|
placeholder={t("form.objectivesPlaceholder")}
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px]"
|
||||||
defaultValue={plan?.objectives ?? ""}
|
defaultValue={plan?.objectives ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardFooter className="justify-end gap-2 px-0">
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
{mode === "create" ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setTemplateOpen(true)}
|
||||||
|
disabled={isWorking || !classId}
|
||||||
|
aria-label={t("templates.createFromTemplate")}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("templates.createFromTemplate")}
|
||||||
|
</Button>
|
||||||
|
<TemplatePickerDialog
|
||||||
|
open={templateOpen}
|
||||||
|
onOpenChange={setTemplateOpen}
|
||||||
|
targetClassId={classId}
|
||||||
|
subjectId={subjectId || undefined}
|
||||||
|
successHref={successHref ?? "/admin/course-plans"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(backHref ?? "/admin/course-plans")}
|
onClick={() => router.push(backHref ?? "/admin/course-plans")}
|
||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
{isWorking ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Check, Trash2, X } from "lucide-react"
|
import { Check, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
@@ -39,11 +41,12 @@ export function CoursePlanItemEditor({
|
|||||||
mode,
|
mode,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: CoursePlanItemEditorProps) {
|
}: CoursePlanItemEditorProps): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
formData.set("planId", planId)
|
formData.set("planId", planId)
|
||||||
@@ -56,7 +59,7 @@ export function CoursePlanItemEditor({
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
toast.error("Invalid form state")
|
toast.error(t("item.invalidState"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,16 +68,16 @@ export function CoursePlanItemEditor({
|
|||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to save week plan")
|
toast.error(res.message || t("item.saveFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to save week plan")
|
toast.error(t("item.saveFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (): Promise<void> => {
|
||||||
if (!item) return
|
if (!item) return
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
@@ -84,16 +87,16 @@ export function CoursePlanItemEditor({
|
|||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to delete")
|
toast.error(res.message || t("item.deleteFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to delete")
|
toast.error(t("item.deleteFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleComplete = async () => {
|
const handleToggleComplete = async (): Promise<void> => {
|
||||||
if (!item) return
|
if (!item) return
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
@@ -102,10 +105,10 @@ export function CoursePlanItemEditor({
|
|||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to update")
|
toast.error(res.message || t("item.updateFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update")
|
toast.error(t("item.updateFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
@@ -116,13 +119,13 @@ export function CoursePlanItemEditor({
|
|||||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{mode === "create" ? "Add Week Plan" : "Edit Week Plan"}
|
{mode === "create" ? t("item.addTitle") : t("item.editTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleSubmit} className="space-y-4">
|
<form action={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="week">Week</Label>
|
<Label htmlFor="week">{t("item.week")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="week"
|
id="week"
|
||||||
name="week"
|
name="week"
|
||||||
@@ -133,7 +136,7 @@ export function CoursePlanItemEditor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="hours">Hours</Label>
|
<Label htmlFor="hours">{t("item.hours")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="hours"
|
id="hours"
|
||||||
name="hours"
|
name="hours"
|
||||||
@@ -145,22 +148,22 @@ export function CoursePlanItemEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="topic">Topic</Label>
|
<Label htmlFor="topic">{t("item.topic")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="topic"
|
id="topic"
|
||||||
name="topic"
|
name="topic"
|
||||||
placeholder="Week topic"
|
placeholder={t("item.topicPlaceholder")}
|
||||||
defaultValue={item?.topic ?? ""}
|
defaultValue={item?.topic ?? ""}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="content">Content</Label>
|
<Label htmlFor="content">{t("item.content")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="content"
|
id="content"
|
||||||
name="content"
|
name="content"
|
||||||
placeholder="Teaching content..."
|
placeholder={t("item.contentPlaceholder")}
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px]"
|
||||||
defaultValue={item?.content ?? ""}
|
defaultValue={item?.content ?? ""}
|
||||||
/>
|
/>
|
||||||
@@ -168,16 +171,16 @@ export function CoursePlanItemEditor({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="textbookChapter">Textbook Chapter</Label>
|
<Label htmlFor="textbookChapter">{t("item.chapter")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="textbookChapter"
|
id="textbookChapter"
|
||||||
name="textbookChapter"
|
name="textbookChapter"
|
||||||
placeholder="e.g. Chapter 3"
|
placeholder={t("item.chapterPlaceholder")}
|
||||||
defaultValue={item?.textbookChapter ?? ""}
|
defaultValue={item?.textbookChapter ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="completedAt">Completed At</Label>
|
<Label htmlFor="completedAt">{t("item.completedAt")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="completedAt"
|
id="completedAt"
|
||||||
name="completedAt"
|
name="completedAt"
|
||||||
@@ -188,11 +191,11 @@ export function CoursePlanItemEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="notes">Notes</Label>
|
<Label htmlFor="notes">{t("item.notes")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
placeholder="Notes..."
|
placeholder={t("item.notesPlaceholder")}
|
||||||
defaultValue={item?.notes ?? ""}
|
defaultValue={item?.notes ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,12 +212,12 @@ export function CoursePlanItemEditor({
|
|||||||
{item.isCompleted ? (
|
{item.isCompleted ? (
|
||||||
<>
|
<>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
Mark Incomplete
|
{t("item.markIncomplete")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
Mark Complete
|
{t("item.markComplete")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -225,7 +228,7 @@ export function CoursePlanItemEditor({
|
|||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{t("item.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -235,10 +238,10 @@ export function CoursePlanItemEditor({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("item.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
{isWorking ? "Saving..." : "Save"}
|
{isWorking ? t("item.saving") : t("item.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Plus, CalendarRange } from "lucide-react"
|
import { Plus, CalendarRange } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -20,15 +23,9 @@ import { Permissions } from "@/shared/types/permissions"
|
|||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import { CoursePlanProgress } from "./course-plan-progress"
|
import { CoursePlanProgress } from "./course-plan-progress"
|
||||||
|
import { isCoursePlanStatus } from "../types"
|
||||||
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||||
|
|
||||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
|
||||||
planning: "Planning",
|
|
||||||
active: "Active",
|
|
||||||
completed: "Completed",
|
|
||||||
paused: "Paused",
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outline"> = {
|
const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outline"> = {
|
||||||
planning: "secondary",
|
planning: "secondary",
|
||||||
active: "default",
|
active: "default",
|
||||||
@@ -36,15 +33,9 @@ const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outlin
|
|||||||
paused: "outline",
|
paused: "outline",
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filter = "all" | CoursePlanStatus
|
const STATUS_VALUES = ["planning", "active", "completed", "paused"] as const
|
||||||
|
|
||||||
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
type Filter = "all" | CoursePlanStatus
|
||||||
{ value: "all", label: "All" },
|
|
||||||
{ value: "planning", label: "Planning" },
|
|
||||||
{ value: "active", label: "Active" },
|
|
||||||
{ value: "completed", label: "Completed" },
|
|
||||||
{ value: "paused", label: "Paused" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function CoursePlanList({
|
export function CoursePlanList({
|
||||||
plans,
|
plans,
|
||||||
@@ -58,7 +49,8 @@ export function CoursePlanList({
|
|||||||
createHref?: string
|
createHref?: string
|
||||||
detailBaseHref?: string
|
detailBaseHref?: string
|
||||||
initialStatus?: Filter
|
initialStatus?: Filter
|
||||||
}) {
|
}): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
const canManageResolved = canManage ?? hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
const canManageResolved = canManage ?? hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
@@ -69,10 +61,11 @@ export function CoursePlanList({
|
|||||||
return plans.filter((p) => p.status === filter)
|
return plans.filter((p) => p.status === filter)
|
||||||
}, [plans, filter])
|
}, [plans, filter])
|
||||||
|
|
||||||
const handleFilterChange = (value: string) => {
|
const handleFilterChange = (value: string): void => {
|
||||||
setFilter(value as Filter)
|
const next: Filter = value === "all" || isCoursePlanStatus(value) ? value : "all"
|
||||||
|
setFilter(next)
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (value !== "all") params.set("status", value)
|
if (next !== "all") params.set("status", next)
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
router.replace(qs ? `?${qs}` : "?")
|
router.replace(qs ? `?${qs}` : "?")
|
||||||
}
|
}
|
||||||
@@ -82,34 +75,31 @@ export function CoursePlanList({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Select value={filter} onValueChange={handleFilterChange}>
|
<Select value={filter} onValueChange={handleFilterChange}>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Filter by status" />
|
<SelectValue placeholder={t("filter.placeholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FILTER_OPTIONS.map((opt) => (
|
<SelectItem value="all">{t("filter.all")}</SelectItem>
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
{STATUS_VALUES.map((s) => (
|
||||||
{opt.label}
|
<SelectItem key={s} value={s}>
|
||||||
|
{t(`status.${s}`)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{canManageResolved && createHref ? (
|
{canManageResolved && createHref ? (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<a href={createHref}>
|
<Link href={createHref}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
New Course Plan
|
{t("list.new")}
|
||||||
</a>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No course plans"
|
title={t("list.empty")}
|
||||||
description={
|
description={plans.length === 0 ? t("list.empty") : t("list.emptyFiltered")}
|
||||||
plans.length === 0
|
|
||||||
? "There are no course plans yet."
|
|
||||||
: "No course plans match the current filter."
|
|
||||||
}
|
|
||||||
icon={CalendarRange}
|
icon={CalendarRange}
|
||||||
className="h-auto border-none shadow-none"
|
className="h-auto border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -121,16 +111,16 @@ export function CoursePlanList({
|
|||||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<CardTitle className="line-clamp-2 text-base">
|
<CardTitle className="line-clamp-2 text-base">
|
||||||
{plan.subjectName ?? "Unknown Subject"}
|
{plan.subjectName ?? t("list.unknownSubject")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_VARIANT[plan.status]} className="shrink-0">
|
<Badge variant={STATUS_VARIANT[plan.status]} className="shrink-0">
|
||||||
{STATUS_LABEL[plan.status]}
|
{t(`status.${plan.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
|
<Badge variant="outline">{plan.className ?? t("list.noClass")}</Badge>
|
||||||
<span>Semester {plan.semester}</span>
|
<span>{t("list.semester", { semester: plan.semester })}</span>
|
||||||
{plan.teacherName ? <span>· {plan.teacherName}</span> : null}
|
{plan.teacherName ? <span>· {plan.teacherName}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<CoursePlanProgress
|
<CoursePlanProgress
|
||||||
@@ -139,16 +129,16 @@ export function CoursePlanList({
|
|||||||
showDetails={false}
|
showDetails={false}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Created {formatDate(plan.createdAt)}
|
{t("list.created", { date: formatDate(plan.createdAt) })}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
return href ? (
|
return href ? (
|
||||||
<a key={plan.id} href={href} className="block h-full">
|
<Link key={plan.id} href={href} className="block h-full">
|
||||||
{card}
|
{card}
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div key={plan.id}>{card}</div>
|
<div key={plan.id}>{card}</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Progress } from "@/shared/components/ui/progress"
|
import { Progress } from "@/shared/components/ui/progress"
|
||||||
|
|
||||||
interface CoursePlanProgressProps {
|
interface CoursePlanProgressProps {
|
||||||
@@ -16,21 +19,22 @@ export function CoursePlanProgress({
|
|||||||
completedItems,
|
completedItems,
|
||||||
totalItems,
|
totalItems,
|
||||||
showDetails = true,
|
showDetails = true,
|
||||||
}: CoursePlanProgressProps) {
|
}: CoursePlanProgressProps): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
const hoursPercent = totalHours > 0 ? Math.round((completedHours / totalHours) * 100) : 0
|
const hoursPercent = totalHours > 0 ? Math.round((completedHours / totalHours) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium">Progress</span>
|
<span className="font-medium">{t("progress.label")}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{completedHours} / {totalHours} hours ({hoursPercent}%)
|
{t("progress.hours", { completed: completedHours, total: totalHours, percent: hoursPercent })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={hoursPercent} className="h-2" />
|
<Progress value={hoursPercent} className="h-2" aria-valuenow={hoursPercent} aria-valuemin={0} aria-valuemax={100} />
|
||||||
{showDetails && typeof completedItems === "number" && typeof totalItems === "number" ? (
|
{showDetails && typeof completedItems === "number" && typeof totalItems === "number" ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{completedItems} of {totalItems} week plans completed
|
{t("progress.weekPlansCompleted", { completed: completedItems, total: totalItems })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
176
src/modules/course-plans/components/sortable-week-row.tsx
Normal file
176
src/modules/course-plans/components/sortable-week-row.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useSortable } from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import { GripVertical, ExternalLink, BookOpen } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import type { CoursePlanWithItems } from "../types"
|
||||||
|
|
||||||
|
interface SortableWeekRowProps {
|
||||||
|
item: CoursePlanWithItems["items"][number]
|
||||||
|
canManage: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
onEdit: (item: CoursePlanWithItems["items"][number]) => void
|
||||||
|
/** 教材列表页地址(按角色不同);提供时章节文本渲染为可跳转链接 */
|
||||||
|
textbooksHref?: string
|
||||||
|
/** 作业列表页地址(按角色不同);提供时显示作业跳转按钮 */
|
||||||
|
homeworkHref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可拖拽排序的周计划表格行。
|
||||||
|
*
|
||||||
|
* 使用 @dnd-kit/sortable 提供拖拽能力:
|
||||||
|
* - 仅当 `canManage=true` 时启用拖拽(通过 disabled 控制)
|
||||||
|
* - 拖拽手柄单独位于第一列,避免点击/复选交互被误触发
|
||||||
|
* - 行点击(非手柄/复选区域)仍可触发 `onEdit`
|
||||||
|
*
|
||||||
|
* 数据联动(P2-3):
|
||||||
|
* - `textbookChapter` 在提供 `textbooksHref` 时渲染为链接,支持一键跳转教材列表
|
||||||
|
* - 行尾在提供 `homeworkHref` 时显示作业跳转按钮
|
||||||
|
*
|
||||||
|
* 可访问性:
|
||||||
|
* - 手柄带 `aria-label`,键盘可通过 sortableKeyboardCoordinates 排序
|
||||||
|
* - 复选框带 `aria-label` 描述所选周次
|
||||||
|
* - 跳转链接带描述性 `aria-label`
|
||||||
|
*/
|
||||||
|
export function SortableWeekRow({
|
||||||
|
item,
|
||||||
|
canManage,
|
||||||
|
isSelected,
|
||||||
|
onToggleSelect,
|
||||||
|
onEdit,
|
||||||
|
textbooksHref,
|
||||||
|
homeworkHref,
|
||||||
|
}: SortableWeekRowProps): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: item.id, disabled: !canManage })
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 10 : 1,
|
||||||
|
position: "relative",
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = canManage ? () => onEdit(item) : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
canManage && "cursor-pointer",
|
||||||
|
isDragging && "opacity-50"
|
||||||
|
)}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
>
|
||||||
|
{canManage ? (
|
||||||
|
<TableCell
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-8"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab rounded p-1 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
|
||||||
|
aria-label={t("detail.dragHandle")}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
{canManage ? (
|
||||||
|
<TableCell
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-8"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onToggleSelect(item.id)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
aria-label={t("detail.selectWeekAria", { week: item.week })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
<TableCell className="font-medium">{item.week}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{item.topic}</p>
|
||||||
|
{item.content ? (
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{item.content}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{item.notes ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
{t("detail.notes", { notes: item.notes })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.hours}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{item.textbookChapter ? (
|
||||||
|
textbooksHref ? (
|
||||||
|
<Link
|
||||||
|
href={textbooksHref}
|
||||||
|
className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={t("detail.viewTextbookAria", { chapter: item.textbookChapter })}
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3 w-3" aria-hidden="true" />
|
||||||
|
{item.textbookChapter}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
item.textbookChapter
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={item.isCompleted ? "default" : "secondary"}>
|
||||||
|
{item.isCompleted ? t("detail.completed") : t("detail.pending")}
|
||||||
|
</Badge>
|
||||||
|
{homeworkHref ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-label={t("detail.viewHomeworkAria")}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Link href={homeworkHref}>
|
||||||
|
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
src/modules/course-plans/components/template-picker-dialog.tsx
Normal file
220
src/modules/course-plans/components/template-picker-dialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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 { FileText, Loader2, Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTemplateCandidatesAction,
|
||||||
|
copyCoursePlanAction,
|
||||||
|
} from "../actions"
|
||||||
|
import { trackCoursePlanEvent } from "../actions"
|
||||||
|
import type { CoursePlanListItem } from "../types"
|
||||||
|
|
||||||
|
interface TemplatePickerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** 当前选中的目标班级 ID(复制目标) */
|
||||||
|
targetClassId?: string
|
||||||
|
/** 可选学科过滤 */
|
||||||
|
subjectId?: string
|
||||||
|
/** 复制成功后的跳转基础路径(如 /admin/course-plans) */
|
||||||
|
successHref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程计划模板选择器(P2-5)。
|
||||||
|
*
|
||||||
|
* 复用现有计划作为模板:列出当前用户可见的计划,支持搜索过滤;
|
||||||
|
* 选择后调用 `copyCoursePlanAction` 克隆到目标班级,并跳转到新计划的编辑页。
|
||||||
|
*
|
||||||
|
* 设计:
|
||||||
|
* - 列表加载通过 Server Action,避免页面级数据预取
|
||||||
|
* - 搜索为客户端过滤(数据量可控时性能足够)
|
||||||
|
* - 克隆完成后触发 `plan_created_from_template` 埋点
|
||||||
|
*
|
||||||
|
* 可访问性:
|
||||||
|
* - 对话框带 `role="dialog"`
|
||||||
|
* - 列表项带 `aria-label` 描述
|
||||||
|
* - 加载状态显示 `aria-live="polite"`
|
||||||
|
*/
|
||||||
|
export function TemplatePickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
targetClassId,
|
||||||
|
subjectId,
|
||||||
|
successHref,
|
||||||
|
}: TemplatePickerDialogProps): JSX.Element {
|
||||||
|
const t = useTranslations("coursePlans")
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [cloning, setCloning] = useState(false)
|
||||||
|
const [candidates, setCandidates] = useState<CoursePlanListItem[]>([])
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
getTemplateCandidatesAction(subjectId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setCandidates(res.data)
|
||||||
|
} else {
|
||||||
|
setCandidates([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCandidates([])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, subjectId])
|
||||||
|
|
||||||
|
const filtered = candidates.filter((c) => {
|
||||||
|
if (!query.trim()) return true
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return (
|
||||||
|
c.subjectName?.toLowerCase().includes(q) ||
|
||||||
|
c.className?.toLowerCase().includes(q) ||
|
||||||
|
c.teacherName?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleConfirm = async (): Promise<void> => {
|
||||||
|
if (!selectedId || !targetClassId) return
|
||||||
|
setCloning(true)
|
||||||
|
try {
|
||||||
|
const res = await copyCoursePlanAction(selectedId, [targetClassId])
|
||||||
|
if (res.success && res.data && res.data.length > 0) {
|
||||||
|
toast.success(t("templates.cloneSuccess"))
|
||||||
|
trackCoursePlanEvent("plan_created_from_template", {
|
||||||
|
sourcePlanId: selectedId,
|
||||||
|
newPlanId: res.data[0],
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
router.push(`${successHref}/${res.data[0]}/edit`)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || t("templates.cloneFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("templates.cloneFailed"))
|
||||||
|
} finally {
|
||||||
|
setCloning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("templates.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("templates.createFromTemplate")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("templates.searchPlaceholder")}
|
||||||
|
className="pl-9"
|
||||||
|
aria-label={t("templates.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
className="flex h-full items-center justify-center gap-2 p-8 text-sm text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
{t("loading.title")}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||||
|
{t("templates.empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y" role="listbox">
|
||||||
|
{filtered.map((plan) => (
|
||||||
|
<li key={plan.id} role="option" aria-selected={selectedId === plan.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(plan.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-3 p-3 text-left transition-colors hover:bg-muted/50",
|
||||||
|
selectedId === plan.id && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Badge variant="outline">{plan.className ?? "—"}</Badge>
|
||||||
|
<Badge variant="outline">{plan.subjectName ?? "—"}</Badge>
|
||||||
|
<Badge>{t(`status.${plan.status}`)}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{plan.teacherName ?? t("detail.unassigned")}
|
||||||
|
{" · "}
|
||||||
|
{t("detail.semester", { semester: plan.semester })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={cloning}
|
||||||
|
>
|
||||||
|
{t("templates.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedId || !targetClassId || cloning}
|
||||||
|
>
|
||||||
|
{cloning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
{t("templates.cloning")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("templates.confirm")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,19 +5,13 @@ import { createId } from "@paralleldrive/cuid2"
|
|||||||
import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import { coursePlanItems, coursePlans } from "@/shared/db/schema"
|
||||||
classes,
|
|
||||||
coursePlanItems,
|
|
||||||
coursePlans,
|
|
||||||
subjects,
|
|
||||||
users,
|
|
||||||
} from "@/shared/db/schema"
|
|
||||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
CoursePlanItem,
|
CoursePlanItem,
|
||||||
CoursePlanListItem,
|
CoursePlanListItem,
|
||||||
CoursePlanStatus,
|
CoursePlanQueryScope,
|
||||||
CoursePlanWithItems,
|
CoursePlanWithItems,
|
||||||
GetCoursePlansParams,
|
GetCoursePlansParams,
|
||||||
GradeCoursePlanProgressItem,
|
GradeCoursePlanProgressItem,
|
||||||
@@ -36,25 +30,11 @@ const toIso = (d: Date | null | undefined): string | null =>
|
|||||||
|
|
||||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||||
|
|
||||||
|
type PlanRow = typeof coursePlans.$inferSelect
|
||||||
|
|
||||||
const mapPlanRow = (
|
const mapPlanRow = (
|
||||||
row: {
|
row: PlanRow,
|
||||||
id: string
|
enrichment: {
|
||||||
classId: string
|
|
||||||
subjectId: string
|
|
||||||
teacherId: string
|
|
||||||
academicYearId: string | null
|
|
||||||
semester: "1" | "2"
|
|
||||||
totalHours: number
|
|
||||||
completedHours: number
|
|
||||||
weeklyHours: number
|
|
||||||
startDate: Date | null
|
|
||||||
endDate: Date | null
|
|
||||||
syllabus: string | null
|
|
||||||
objectives: string | null
|
|
||||||
status: CoursePlanStatus
|
|
||||||
createdBy: string
|
|
||||||
createdAt: Date
|
|
||||||
updatedAt: Date
|
|
||||||
className: string | null
|
className: string | null
|
||||||
subjectName: string | null
|
subjectName: string | null
|
||||||
teacherName: string | null
|
teacherName: string | null
|
||||||
@@ -77,27 +57,14 @@ const mapPlanRow = (
|
|||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
createdAt: toIsoRequired(row.createdAt),
|
createdAt: toIsoRequired(row.createdAt),
|
||||||
updatedAt: toIsoRequired(row.updatedAt),
|
updatedAt: toIsoRequired(row.updatedAt),
|
||||||
className: row.className,
|
className: enrichment.className,
|
||||||
subjectName: row.subjectName,
|
subjectName: enrichment.subjectName,
|
||||||
teacherName: row.teacherName,
|
teacherName: enrichment.teacherName,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapItemRow = (
|
type ItemRow = typeof coursePlanItems.$inferSelect
|
||||||
row: {
|
|
||||||
id: string
|
const mapItemRow = (row: ItemRow): CoursePlanItem => ({
|
||||||
planId: string
|
|
||||||
week: number
|
|
||||||
topic: string
|
|
||||||
content: string | null
|
|
||||||
hours: number
|
|
||||||
textbookChapter: string | null
|
|
||||||
notes: string | null
|
|
||||||
isCompleted: boolean
|
|
||||||
completedAt: Date | null
|
|
||||||
createdAt: Date
|
|
||||||
updatedAt: Date
|
|
||||||
}
|
|
||||||
): CoursePlanItem => ({
|
|
||||||
id: row.id,
|
id: row.id,
|
||||||
planId: row.planId,
|
planId: row.planId,
|
||||||
week: Number(row.week),
|
week: Number(row.week),
|
||||||
@@ -112,52 +79,91 @@ const mapItemRow = (
|
|||||||
updatedAt: toIsoRequired(row.updatedAt),
|
updatedAt: toIsoRequired(row.updatedAt),
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildPlanSelect = () =>
|
/**
|
||||||
db
|
* 批量解析班级/科目/教师名称(通过对方 data-access,不直接 JOIN 其他模块表)。
|
||||||
.select({
|
* 解耦 P1-1:course-plans 仅查询自己的 course_plans 表,
|
||||||
id: coursePlans.id,
|
* 名称解析委托给 classes/school/users 模块的 data-access。
|
||||||
classId: coursePlans.classId,
|
*/
|
||||||
subjectId: coursePlans.subjectId,
|
async function enrichPlanRows(
|
||||||
teacherId: coursePlans.teacherId,
|
rows: PlanRow[]
|
||||||
academicYearId: coursePlans.academicYearId,
|
): Promise<CoursePlanListItem[]> {
|
||||||
semester: coursePlans.semester,
|
if (rows.length === 0) return []
|
||||||
totalHours: coursePlans.totalHours,
|
|
||||||
completedHours: coursePlans.completedHours,
|
const { getClassNamesByIds } = await import("@/modules/classes/data-access")
|
||||||
weeklyHours: coursePlans.weeklyHours,
|
const { getSubjectNameMapByIds } = await import("@/modules/school/data-access")
|
||||||
startDate: coursePlans.startDate,
|
const { getUserNamesByIds } = await import("@/modules/users/data-access")
|
||||||
endDate: coursePlans.endDate,
|
|
||||||
syllabus: coursePlans.syllabus,
|
const classIds = Array.from(new Set(rows.map((r) => r.classId)))
|
||||||
objectives: coursePlans.objectives,
|
const subjectIds = Array.from(new Set(rows.map((r) => r.subjectId)))
|
||||||
status: coursePlans.status,
|
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId)))
|
||||||
createdBy: coursePlans.createdBy,
|
|
||||||
createdAt: coursePlans.createdAt,
|
const [classNameMap, subjectNameMap, teacherNameMap] = await Promise.all([
|
||||||
updatedAt: coursePlans.updatedAt,
|
getClassNamesByIds(classIds),
|
||||||
className: classes.name,
|
getSubjectNameMapByIds(subjectIds),
|
||||||
subjectName: subjects.name,
|
getUserNamesByIds(teacherIds),
|
||||||
teacherName: users.name,
|
])
|
||||||
|
|
||||||
|
return rows.map((row) =>
|
||||||
|
mapPlanRow(row, {
|
||||||
|
className: classNameMap.get(row.classId) ?? null,
|
||||||
|
subjectName: subjectNameMap.get(row.subjectId) ?? null,
|
||||||
|
teacherName: teacherNameMap.get(row.teacherId)?.name ?? null,
|
||||||
})
|
})
|
||||||
.from(coursePlans)
|
)
|
||||||
.leftJoin(classes, eq(classes.id, coursePlans.classId))
|
}
|
||||||
.leftJoin(subjects, eq(subjects.id, coursePlans.subjectId))
|
|
||||||
.leftJoin(users, eq(users.id, coursePlans.teacherId))
|
/**
|
||||||
|
* 单条计划的名称解析(用于详情查询)。
|
||||||
|
*/
|
||||||
|
async function enrichPlanRow(row: PlanRow): Promise<CoursePlanListItem> {
|
||||||
|
const enriched = await enrichPlanRows([row])
|
||||||
|
return enriched[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildScopeCondition = (
|
||||||
|
scope?: CoursePlanQueryScope
|
||||||
|
): SQL[] => {
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
if (!scope) return conditions
|
||||||
|
|
||||||
|
// 管理员拥有全局视图,不加过滤
|
||||||
|
if (scope.isAdmin) return conditions
|
||||||
|
|
||||||
|
// 教师视角:仅查看自己负责的计划
|
||||||
|
if (scope.teacherId) {
|
||||||
|
conditions.push(eq(coursePlans.teacherId, scope.teacherId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级范围过滤(学生/家长/年级主任)
|
||||||
|
if (scope.classIds && scope.classIds.length > 0) {
|
||||||
|
conditions.push(inArray(coursePlans.classId, scope.classIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若非 admin 且未提供任何过滤条件,默认仅返回自己创建的
|
||||||
|
if (!scope.isAdmin && !scope.teacherId && (!scope.classIds || scope.classIds.length === 0)) {
|
||||||
|
conditions.push(eq(coursePlans.createdBy, scope.userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
}
|
||||||
|
|
||||||
export const getCoursePlans = cache(
|
export const getCoursePlans = cache(
|
||||||
async (params?: GetCoursePlansParams): Promise<CoursePlanListItem[]> => {
|
async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise<CoursePlanListItem[]> => {
|
||||||
try {
|
try {
|
||||||
const conditions: SQL[] = []
|
const conditions: SQL[] = [...buildScopeCondition(scope)]
|
||||||
if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId))
|
if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId))
|
||||||
if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId))
|
if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId))
|
||||||
if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId))
|
if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId))
|
||||||
if (params?.status)
|
if (params?.status)
|
||||||
conditions.push(eq(coursePlans.status, params.status))
|
conditions.push(eq(coursePlans.status, params.status))
|
||||||
|
|
||||||
const query = buildPlanSelect()
|
const query = db.select().from(coursePlans)
|
||||||
const rows = await (conditions.length > 0
|
const rows = await (conditions.length > 0
|
||||||
? query.where(and(...conditions))
|
? query.where(and(...conditions))
|
||||||
: query
|
: query
|
||||||
).orderBy(desc(coursePlans.createdAt))
|
).orderBy(desc(coursePlans.createdAt))
|
||||||
|
|
||||||
return rows.map(mapPlanRow)
|
return await enrichPlanRows(rows)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getCoursePlans failed:", error)
|
console.error("getCoursePlans failed:", error)
|
||||||
return []
|
return []
|
||||||
@@ -166,14 +172,19 @@ export const getCoursePlans = cache(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const getCoursePlanById = cache(
|
export const getCoursePlanById = cache(
|
||||||
async (id: string): Promise<CoursePlanWithItems | null> => {
|
async (id: string, scope?: CoursePlanQueryScope): Promise<CoursePlanWithItems | null> => {
|
||||||
try {
|
try {
|
||||||
const [planRow] = await buildPlanSelect()
|
const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)]
|
||||||
.where(eq(coursePlans.id, id))
|
const [planRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(coursePlans)
|
||||||
|
.where(and(...conditions))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!planRow) return null
|
if (!planRow) return null
|
||||||
|
|
||||||
|
const enriched = await enrichPlanRow(planRow)
|
||||||
|
|
||||||
const itemRows = await db
|
const itemRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(coursePlanItems)
|
.from(coursePlanItems)
|
||||||
@@ -181,7 +192,7 @@ export const getCoursePlanById = cache(
|
|||||||
.orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt))
|
.orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mapPlanRow(planRow),
|
...enriched,
|
||||||
items: itemRows.map(mapItemRow),
|
items: itemRows.map(mapItemRow),
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -312,53 +323,119 @@ export async function reorderCoursePlanItems(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
|
/**
|
||||||
|
* 批量标记周计划条目完成状态(P2-4 批量操作)。
|
||||||
|
*/
|
||||||
|
export async function bulkUpdateItemCompleted(
|
||||||
|
itemIds: string[],
|
||||||
|
completed: boolean
|
||||||
|
): Promise<number> {
|
||||||
|
if (itemIds.length === 0) return 0
|
||||||
|
// schema 列为 Date 类型,使用 Date 对象而非字符串
|
||||||
|
const completedAt = completed ? new Date() : null
|
||||||
|
await db
|
||||||
|
.update(coursePlanItems)
|
||||||
|
.set({ isCompleted: completed, completedAt })
|
||||||
|
.where(inArray(coursePlanItems.id, itemIds))
|
||||||
|
return itemIds.length
|
||||||
|
}
|
||||||
|
|
||||||
export const getSubjectOptions = cache(async (): Promise<{ id: string; name: string }[]> => {
|
/**
|
||||||
try {
|
* 复制课程计划到其他班级(P2-4 批量操作)。
|
||||||
const rows = await db
|
* 复制基本信息(不含 completedHours)和所有周计划条目。
|
||||||
.select({ id: subjects.id, name: subjects.name })
|
*/
|
||||||
.from(subjects)
|
export async function copyCoursePlanToClasses(
|
||||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
sourcePlanId: string,
|
||||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
targetClassIds: string[]
|
||||||
} catch (error) {
|
): Promise<string[]> {
|
||||||
console.error("getSubjectOptions failed:", error)
|
if (targetClassIds.length === 0) return []
|
||||||
return []
|
|
||||||
|
const [source] = await db
|
||||||
|
.select()
|
||||||
|
.from(coursePlans)
|
||||||
|
.where(eq(coursePlans.id, sourcePlanId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!source) return []
|
||||||
|
|
||||||
|
const sourceItems = await db
|
||||||
|
.select()
|
||||||
|
.from(coursePlanItems)
|
||||||
|
.where(eq(coursePlanItems.planId, sourcePlanId))
|
||||||
|
|
||||||
|
const createdIds: string[] = []
|
||||||
|
|
||||||
|
for (const classId of targetClassIds) {
|
||||||
|
const newPlanId = createId()
|
||||||
|
await db.insert(coursePlans).values({
|
||||||
|
id: newPlanId,
|
||||||
|
classId,
|
||||||
|
subjectId: source.subjectId,
|
||||||
|
teacherId: source.teacherId,
|
||||||
|
academicYearId: source.academicYearId,
|
||||||
|
semester: source.semester,
|
||||||
|
totalHours: source.totalHours,
|
||||||
|
completedHours: 0,
|
||||||
|
weeklyHours: source.weeklyHours,
|
||||||
|
startDate: source.startDate,
|
||||||
|
endDate: source.endDate,
|
||||||
|
syllabus: source.syllabus,
|
||||||
|
objectives: source.objectives,
|
||||||
|
status: "planning",
|
||||||
|
createdBy: source.createdBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const item of sourceItems) {
|
||||||
|
const newItemId = createId()
|
||||||
|
await db.insert(coursePlanItems).values({
|
||||||
|
id: newItemId,
|
||||||
|
planId: newPlanId,
|
||||||
|
week: item.week,
|
||||||
|
topic: item.topic,
|
||||||
|
content: item.content,
|
||||||
|
hours: item.hours,
|
||||||
|
textbookChapter: item.textbookChapter,
|
||||||
|
notes: item.notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createdIds.push(newPlanId)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
return createdIds
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。
|
* 年级仪表盘 - 维度4:获取年级下所有班级的教学计划进度。
|
||||||
* 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询 course_plans,
|
* 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询 course_plans,
|
||||||
* 关联 course_plan_items 统计条目完成情况。
|
* 关联 course_plan_items 统计条目完成情况。
|
||||||
|
* 名称解析通过 data-access 批量查询,不 JOIN 其他模块表。
|
||||||
*/
|
*/
|
||||||
export const getGradeCoursePlanProgress = cache(
|
export const getGradeCoursePlanProgress = cache(
|
||||||
async (params: { gradeId: string }): Promise<GradeCoursePlanProgressResult> => {
|
async (params: { gradeId: string }): Promise<GradeCoursePlanProgressResult> => {
|
||||||
const { getClassesByGradeId } = await import("@/modules/classes/data-access")
|
const { getClassesByGradeId } = await import("@/modules/classes/data-access")
|
||||||
const classRows = await getClassesByGradeId(params.gradeId)
|
const classRows = await getClassesByGradeId(params.gradeId)
|
||||||
|
|
||||||
if (classRows.length === 0) {
|
const empty: GradeCoursePlanProgressResult = {
|
||||||
return {
|
gradeId: params.gradeId,
|
||||||
gradeId: params.gradeId,
|
overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 },
|
||||||
overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 },
|
items: [],
|
||||||
items: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (classRows.length === 0) return empty
|
||||||
|
|
||||||
const classIds = classRows.map((c) => c.id)
|
const classIds = classRows.map((c) => c.id)
|
||||||
|
|
||||||
// 查询年级下所有教学计划(含班级/科目/教师名称)
|
// 查询年级下所有教学计划(仅 course_plans 表)
|
||||||
const planRows = await buildPlanSelect()
|
const planRows = await db
|
||||||
|
.select()
|
||||||
|
.from(coursePlans)
|
||||||
.where(inArray(coursePlans.classId, classIds))
|
.where(inArray(coursePlans.classId, classIds))
|
||||||
.orderBy(asc(classes.name), asc(subjects.name))
|
.orderBy(desc(coursePlans.createdAt))
|
||||||
|
|
||||||
if (planRows.length === 0) {
|
if (planRows.length === 0) return empty
|
||||||
return {
|
|
||||||
gradeId: params.gradeId,
|
|
||||||
overall: { totalPlans: 0, totalHours: 0, completedHours: 0, progressRate: 0, activePlans: 0, completedPlans: 0 },
|
|
||||||
items: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const planIds = planRows.map((p) => p.id)
|
const planIds = planRows.map((p) => p.id)
|
||||||
|
|
||||||
@@ -379,9 +456,14 @@ export const getGradeCoursePlanProgress = cache(
|
|||||||
itemStatsByPlan.set(it.planId, entry)
|
itemStatsByPlan.set(it.planId, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: GradeCoursePlanProgressItem[] = planRows.map((p) => {
|
// 批量解析名称
|
||||||
const totalHours = Number(p.totalHours)
|
const enriched = await enrichPlanRows(planRows)
|
||||||
const completedHours = Number(p.completedHours)
|
|
||||||
|
const classNameMap = new Map(classRows.map((c) => [c.id, c.name] as const))
|
||||||
|
|
||||||
|
const items: GradeCoursePlanProgressItem[] = enriched.map((p) => {
|
||||||
|
const totalHours = p.totalHours
|
||||||
|
const completedHours = p.completedHours
|
||||||
const progressRate = totalHours > 0
|
const progressRate = totalHours > 0
|
||||||
? Math.round((completedHours / totalHours) * 1000) / 10
|
? Math.round((completedHours / totalHours) * 1000) / 10
|
||||||
: 0
|
: 0
|
||||||
@@ -389,7 +471,7 @@ export const getGradeCoursePlanProgress = cache(
|
|||||||
return {
|
return {
|
||||||
planId: p.id,
|
planId: p.id,
|
||||||
classId: p.classId,
|
classId: p.classId,
|
||||||
className: p.className,
|
className: classNameMap.get(p.classId) ?? p.className,
|
||||||
subjectId: p.subjectId,
|
subjectId: p.subjectId,
|
||||||
subjectName: p.subjectName,
|
subjectName: p.subjectName,
|
||||||
teacherName: p.teacherName,
|
teacherName: p.teacherName,
|
||||||
|
|||||||
169
src/modules/course-plans/lib/calendar-utils.ts
Normal file
169
src/modules/course-plans/lib/calendar-utils.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { CoursePlanWithItems } from "../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* course-plans 日历视图工具(纯函数,便于单测)。
|
||||||
|
*
|
||||||
|
* 将周计划项映射到日历日期范围:
|
||||||
|
* - 若 plan.startDate 存在:第 N 周对应 [startDate + (N-1)*7, startDate + N*7 - 1]
|
||||||
|
* - 若 plan.startDate 缺失:返回 null(无法映射到日期)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 日历事件(一个周计划项对应一个事件) */
|
||||||
|
export interface CalendarEvent {
|
||||||
|
/** 事件唯一 ID(使用 item.id) */
|
||||||
|
id: string
|
||||||
|
/** 事件标题(使用 item.topic) */
|
||||||
|
title: string
|
||||||
|
/** 起始日期(ISO 字符串,YYYY-MM-DD) */
|
||||||
|
startDate: string
|
||||||
|
/** 结束日期(ISO 字符串,YYYY-MM-DD,含) */
|
||||||
|
endDate: string
|
||||||
|
/** 周次 */
|
||||||
|
week: number
|
||||||
|
/** 课时 */
|
||||||
|
hours: number
|
||||||
|
/** 是否已完成 */
|
||||||
|
isCompleted: boolean
|
||||||
|
/** 教材章节 */
|
||||||
|
textbookChapter: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将日期对象格式化为 YYYY-MM-DD(不依赖 date-fns) */
|
||||||
|
export function formatDateISO(date: Date): string {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const d = String(date.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 YYYY-MM-DD 字符串为本地日期(避免 UTC 偏移) */
|
||||||
|
export function parseISODate(iso: string): Date {
|
||||||
|
const [y, m, d] = iso.split("-").map(Number)
|
||||||
|
return new Date(y, (m ?? 1) - 1, d ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算某日所在周的周日(作为周起始;遵循 ISO 8601 周一为周首) */
|
||||||
|
export function startOfWeek(date: Date): Date {
|
||||||
|
const d = new Date(date)
|
||||||
|
const day = d.getDay() // 0=Sunday, 1=Monday, ...
|
||||||
|
const diff = (day === 0 ? -6 : 1 - day) // 周一为周首
|
||||||
|
d.setDate(d.getDate() + diff)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算某日所在月的首日 */
|
||||||
|
export function startOfMonth(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算某日所在月的末日 */
|
||||||
|
export function endOfMonth(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在日期上加天数 */
|
||||||
|
export function addDays(date: Date, days: number): Date {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setDate(d.getDate() + days)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在日期上加月数 */
|
||||||
|
export function addMonths(date: Date, months: number): Date {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setMonth(d.getMonth() + months)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断两日期是否同一天 */
|
||||||
|
export function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断日期 a 是否在 [start, end] 区间内(含端点) */
|
||||||
|
export function isWithinRange(date: Date, start: Date, end: Date): boolean {
|
||||||
|
const t = date.getTime()
|
||||||
|
return t >= start.getTime() && t <= end.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将课程计划及其周计划项转换为日历事件列表(纯函数)。
|
||||||
|
*
|
||||||
|
* - 需要 `plan.startDate` 才能计算每周的日期范围
|
||||||
|
* - 第 N 周对应日期范围 [startDate + (N-1)*7, startDate + N*7 - 1]
|
||||||
|
*
|
||||||
|
* @returns 事件列表;若无 startDate 则返回空数组
|
||||||
|
*/
|
||||||
|
export function planToCalendarEvents(plan: CoursePlanWithItems): CalendarEvent[] {
|
||||||
|
if (!plan.startDate) return []
|
||||||
|
|
||||||
|
const start = parseISODate(plan.startDate)
|
||||||
|
return plan.items.map((item) => {
|
||||||
|
const weekStart = addDays(start, (item.week - 1) * 7)
|
||||||
|
const weekEnd = addDays(weekStart, 6) // 周一至周日
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.topic,
|
||||||
|
startDate: formatDateISO(weekStart),
|
||||||
|
endDate: formatDateISO(weekEnd),
|
||||||
|
week: item.week,
|
||||||
|
hours: item.hours,
|
||||||
|
isCompleted: item.isCompleted,
|
||||||
|
textbookChapter: item.textbookChapter,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成日历网格:返回覆盖给定月份所需的 6 周 × 7 天 = 42 天的日期数组。
|
||||||
|
*
|
||||||
|
* 网格从月份首日所在周的周一开始,确保整月可见。
|
||||||
|
*
|
||||||
|
* @param monthDate 月份内任意一天
|
||||||
|
* @returns 42 个 Date 对象(6 行 × 7 列)
|
||||||
|
*/
|
||||||
|
export function buildMonthGrid(monthDate: Date): Date[] {
|
||||||
|
const monthStart = startOfMonth(monthDate)
|
||||||
|
const gridStart = startOfWeek(monthStart)
|
||||||
|
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤在给定日期范围内有重叠的事件。
|
||||||
|
*
|
||||||
|
* @param events 事件列表
|
||||||
|
* @param rangeStart 范围开始
|
||||||
|
* @param rangeEnd 范围结束
|
||||||
|
*/
|
||||||
|
export function filterEventsInRange(
|
||||||
|
events: readonly CalendarEvent[],
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
return events.filter((event) => {
|
||||||
|
const eventStart = parseISODate(event.startDate)
|
||||||
|
const eventEnd = parseISODate(event.endDate)
|
||||||
|
// 区间相交判断:eventStart <= rangeEnd && eventEnd >= rangeStart
|
||||||
|
return eventStart.getTime() <= rangeEnd.getTime() &&
|
||||||
|
eventEnd.getTime() >= rangeStart.getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回某一天的事件列表(事件日期范围包含该天)。
|
||||||
|
*/
|
||||||
|
export function eventsOnDay(
|
||||||
|
events: readonly CalendarEvent[],
|
||||||
|
day: Date,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
return events.filter((event) => {
|
||||||
|
const eventStart = parseISODate(event.startDate)
|
||||||
|
const eventEnd = parseISODate(event.endDate)
|
||||||
|
return isWithinRange(day, eventStart, eventEnd)
|
||||||
|
})
|
||||||
|
}
|
||||||
90
src/modules/course-plans/lib/export-utils.ts
Normal file
90
src/modules/course-plans/lib/export-utils.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ExportColumn, ExportRow } from "@/shared/lib/export-utils"
|
||||||
|
import { exportCSV } from "@/shared/lib/export-utils"
|
||||||
|
|
||||||
|
import type { CoursePlanWithItems } from "../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* course-plans 模块导出工具(纯函数 + 客户端下载)。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - `planToExportRows` 为纯函数,便于单测;不直接依赖 i18n,状态文本由调用方通过 columnLabels 传入
|
||||||
|
* - `exportCoursePlanReport` 执行客户端下载,触发埋点由调用方处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 导出列标识(与 ExportRow 的 key 对应) */
|
||||||
|
export type CoursePlanExportColumnKey =
|
||||||
|
| "week"
|
||||||
|
| "topic"
|
||||||
|
| "content"
|
||||||
|
| "hours"
|
||||||
|
| "textbookChapter"
|
||||||
|
| "status"
|
||||||
|
| "notes"
|
||||||
|
|
||||||
|
/** 列标签映射(由调用方传入已本地化的字符串) */
|
||||||
|
export interface CoursePlanColumnLabels {
|
||||||
|
week: string
|
||||||
|
topic: string
|
||||||
|
content: string
|
||||||
|
hours: string
|
||||||
|
textbookChapter: string
|
||||||
|
status: string
|
||||||
|
notes: string
|
||||||
|
/** 已完成状态文本 */
|
||||||
|
completed: string
|
||||||
|
/** 待完成状态文本 */
|
||||||
|
pending: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建导出列配置(按固定顺序)。
|
||||||
|
*/
|
||||||
|
export function buildExportColumns(labels: CoursePlanColumnLabels): readonly ExportColumn[] {
|
||||||
|
return [
|
||||||
|
{ key: "week", label: labels.week },
|
||||||
|
{ key: "topic", label: labels.topic },
|
||||||
|
{ key: "content", label: labels.content },
|
||||||
|
{ key: "hours", label: labels.hours },
|
||||||
|
{ key: "textbookChapter", label: labels.textbookChapter },
|
||||||
|
{ key: "status", label: labels.status },
|
||||||
|
{ key: "notes", label: labels.notes },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将课程计划转换为导出行(纯函数)。
|
||||||
|
*
|
||||||
|
* @param plan 课程计划
|
||||||
|
* @param labels 列标签 + 状态文本
|
||||||
|
*/
|
||||||
|
export function planToExportRows(
|
||||||
|
plan: CoursePlanWithItems,
|
||||||
|
labels: CoursePlanColumnLabels,
|
||||||
|
): ExportRow[] {
|
||||||
|
return plan.items.map((item) => ({
|
||||||
|
week: item.week,
|
||||||
|
topic: item.topic,
|
||||||
|
content: item.content ?? "",
|
||||||
|
hours: item.hours,
|
||||||
|
textbookChapter: item.textbookChapter ?? "",
|
||||||
|
status: item.isCompleted ? labels.completed : labels.pending,
|
||||||
|
notes: item.notes ?? "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端导出课程计划教学进度报告为 CSV。
|
||||||
|
*
|
||||||
|
* @param plan 课程计划
|
||||||
|
* @param labels 列标签 + 状态文本
|
||||||
|
* @param filename 文件名(不含扩展名)
|
||||||
|
*/
|
||||||
|
export function exportCoursePlanReport(
|
||||||
|
plan: CoursePlanWithItems,
|
||||||
|
labels: CoursePlanColumnLabels,
|
||||||
|
filename: string,
|
||||||
|
): void {
|
||||||
|
const columns = buildExportColumns(labels)
|
||||||
|
const rows = planToExportRows(plan, labels)
|
||||||
|
exportCSV(rows, columns, filename)
|
||||||
|
}
|
||||||
@@ -178,3 +178,36 @@ export const UpdateCoursePlanItemSchema = z
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
export type UpdateCoursePlanItemInput = z.infer<typeof UpdateCoursePlanItemSchema>
|
export type UpdateCoursePlanItemInput = z.infer<typeof UpdateCoursePlanItemSchema>
|
||||||
|
|
||||||
|
// ── Action 入参验证(P1-6)──────────────────────────────────
|
||||||
|
|
||||||
|
export const GetCoursePlansParamsSchema = z.object({
|
||||||
|
classId: z.string().trim().min(1).optional(),
|
||||||
|
teacherId: z.string().trim().min(1).optional(),
|
||||||
|
subjectId: z.string().trim().min(1).optional(),
|
||||||
|
status: z.enum(["planning", "active", "completed", "paused"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GradeIdSchema = z.object({
|
||||||
|
gradeId: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReorderItemsSchema = z.object({
|
||||||
|
planId: z.string().trim().min(1),
|
||||||
|
items: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
week: z.number().int().min(1),
|
||||||
|
})
|
||||||
|
).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BulkToggleSchema = z.object({
|
||||||
|
itemIds: z.array(z.string().trim().min(1)).min(1),
|
||||||
|
completed: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CopyPlanSchema = z.object({
|
||||||
|
sourcePlanId: z.string().trim().min(1),
|
||||||
|
targetClassIds: z.array(z.string().trim().min(1)).min(1),
|
||||||
|
})
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ export interface ReorderCoursePlanItemInput {
|
|||||||
week: number
|
week: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据范围上下文,用于在 data-access 层进行权限过滤。
|
||||||
|
* 由调用方(Server Action / 页面)从 AuthContext.dataScope 解析后传入。
|
||||||
|
*/
|
||||||
|
export interface CoursePlanQueryScope {
|
||||||
|
/** 当前用户 ID */
|
||||||
|
userId: string
|
||||||
|
/** 是否管理员(拥有全局视图) */
|
||||||
|
isAdmin: boolean
|
||||||
|
/** 可访问的班级 ID 列表(非 admin 时用于过滤) */
|
||||||
|
classIds?: string[]
|
||||||
|
/** 教师视角:仅查看自己负责的计划 */
|
||||||
|
teacherId?: string
|
||||||
|
/** 学生/家长视角:仅查看特定孩子的班级 */
|
||||||
|
studentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 年级仪表盘 - 维度4:年级下各班级/科目的课本进度。
|
* 年级仪表盘 - 维度4:年级下各班级/科目的课本进度。
|
||||||
*/
|
*/
|
||||||
@@ -95,3 +112,50 @@ export interface GradeCoursePlanProgressResult {
|
|||||||
/** 按班级 + 科目拆分的进度列表 */
|
/** 按班级 + 科目拆分的进度列表 */
|
||||||
items: GradeCoursePlanProgressItem[]
|
items: GradeCoursePlanProgressItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 类型守卫 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const isCoursePlanStatus = (v: unknown): v is CoursePlanStatus =>
|
||||||
|
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||||
|
|
||||||
|
export const isCoursePlanSemester = (v: unknown): v is CoursePlanSemester =>
|
||||||
|
v === "1" || v === "2"
|
||||||
|
|
||||||
|
// ── 角色配置驱动:决定各角色视图渲染哪些 Widget ────────────
|
||||||
|
|
||||||
|
export type CoursePlanWidgetId =
|
||||||
|
| "list"
|
||||||
|
| "progress"
|
||||||
|
| "calendar"
|
||||||
|
| "bulkActions"
|
||||||
|
| "export"
|
||||||
|
| "templates"
|
||||||
|
|
||||||
|
export interface RoleWidgetConfig {
|
||||||
|
widgets: CoursePlanWidgetId[]
|
||||||
|
canManage: boolean
|
||||||
|
detailBaseHref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_WIDGET_CONFIG: Record<"admin" | "teacher" | "parent" | "student", RoleWidgetConfig> = {
|
||||||
|
admin: {
|
||||||
|
widgets: ["list", "progress", "calendar", "bulkActions", "export", "templates"],
|
||||||
|
canManage: true,
|
||||||
|
detailBaseHref: "/admin/course-plans",
|
||||||
|
},
|
||||||
|
teacher: {
|
||||||
|
widgets: ["list", "progress", "calendar"],
|
||||||
|
canManage: true,
|
||||||
|
detailBaseHref: "/teacher/course-plans",
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
widgets: ["list", "progress"],
|
||||||
|
canManage: false,
|
||||||
|
detailBaseHref: "/parent/course-plans",
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
widgets: ["list", "progress"],
|
||||||
|
canManage: false,
|
||||||
|
detailBaseHref: "/student/course-plans",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
@@ -26,6 +27,9 @@ import {
|
|||||||
startHomeworkSubmission,
|
startHomeworkSubmission,
|
||||||
batchAutoGradeSubmissions,
|
batchAutoGradeSubmissions,
|
||||||
} from "./data-access-write"
|
} from "./data-access-write"
|
||||||
|
import { getScansBySubmissionId } from "./data-access-scans"
|
||||||
|
import { getExcellentSubmissions, getUnsubmittedStudents, getHomeworkAssignmentById } from "./data-access"
|
||||||
|
import type { ExcellentSubmissionItem, ScanAttachment } from "./types"
|
||||||
|
|
||||||
const parseStudentIds = (raw: string): string[] => {
|
const parseStudentIds = (raw: string): string[] => {
|
||||||
return raw
|
return raw
|
||||||
@@ -34,6 +38,38 @@ const parseStudentIds = (raw: string): string[] => {
|
|||||||
.filter((s) => s.length > 0)
|
.filter((s) => s.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Zod 校验返回的 i18n 键翻译为当前 locale 的字符串。
|
||||||
|
*
|
||||||
|
* schema.ts 中所有错误消息均使用 `homework.form.error.*` 命名空间的 i18n 键,
|
||||||
|
* 此函数在 Server Action 校验失败时调用,将 `fieldErrors` 中的键翻译为
|
||||||
|
* 实际字符串,并返回首条错误作为 toast 显示的 `message`。
|
||||||
|
*/
|
||||||
|
async function translateZodErrors(
|
||||||
|
fieldErrors: Record<string, string[] | undefined>
|
||||||
|
): Promise<{ errors: Record<string, string[]>; firstMessage: string }> {
|
||||||
|
const t = await getTranslations("examHomework")
|
||||||
|
const translated: Record<string, string[]> = {}
|
||||||
|
let firstMessage = t("homework.form.error.invalidForm")
|
||||||
|
|
||||||
|
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||||
|
if (!messages || messages.length === 0) continue
|
||||||
|
const translatedMessages = messages.map((msg) => {
|
||||||
|
// 仅翻译形如 "homework.form.error.xxx" 的 i18n 键,其他原样返回
|
||||||
|
if (msg.startsWith("homework.form.error.")) {
|
||||||
|
return t(msg)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
translated[field] = translatedMessages
|
||||||
|
if (firstMessage === t("homework.form.error.invalidForm")) {
|
||||||
|
firstMessage = translatedMessages[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors: translated, firstMessage }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批改后处理:自动采集错题 + 更新知识点掌握度。
|
* 批改后处理:自动采集错题 + 更新知识点掌握度。
|
||||||
*
|
*
|
||||||
@@ -84,10 +120,13 @@ export async function createHomeworkAssignmentAction(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
const { errors, firstMessage } = await translateZodErrors(
|
||||||
|
parsed.error.flatten().fieldErrors
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Invalid form data",
|
message: firstMessage,
|
||||||
errors: parsed.error.flatten().fieldErrors,
|
errors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +243,14 @@ export async function startHomeworkSubmissionAction(
|
|||||||
|
|
||||||
revalidatePath("/student/learning/assignments")
|
revalidatePath("/student/learning/assignments")
|
||||||
|
|
||||||
|
await trackExamEvent("homework.started", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: assignmentId,
|
||||||
|
properties: {
|
||||||
|
submissionId: result.submissionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return { success: true, message: "Started", data: result.submissionId }
|
return { success: true, message: "Started", data: result.submissionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
@@ -234,6 +281,14 @@ export async function saveHomeworkAnswerAction(
|
|||||||
|
|
||||||
await saveHomeworkAnswer(submissionId, questionId, payload)
|
await saveHomeworkAnswer(submissionId, questionId, payload)
|
||||||
|
|
||||||
|
await trackExamEvent("homework.answer_saved", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: submissionId,
|
||||||
|
properties: {
|
||||||
|
questionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return { success: true, message: "Saved", data: submissionId }
|
return { success: true, message: "Saved", data: submissionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
@@ -438,15 +493,6 @@ export async function batchAutoGradeSubmissionsAction(
|
|||||||
// 答题拍照上传:扫描图管理(基于 fileAttachments 表)
|
// 答题拍照上传:扫描图管理(基于 fileAttachments 表)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface ScanAttachment {
|
|
||||||
fileId: string
|
|
||||||
url: string
|
|
||||||
filename: string
|
|
||||||
originalName: string
|
|
||||||
/** 页码(按创建时间排序,从 1 开始) */
|
|
||||||
page: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取某次提交的所有答题扫描图。
|
* 获取某次提交的所有答题扫描图。
|
||||||
* 扫描图存储在 fileAttachments 表中,targetType="homework", targetId=submissionId。
|
* 扫描图存储在 fileAttachments 表中,targetType="homework", targetId=submissionId。
|
||||||
@@ -487,34 +533,7 @@ export async function getScansAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { db } = await import("@/shared/db")
|
const scans = await getScansBySubmissionId(submissionId)
|
||||||
const { fileAttachments } = await import("@/shared/db/schema")
|
|
||||||
const { eq, and, asc } = await import("drizzle-orm")
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: fileAttachments.id,
|
|
||||||
url: fileAttachments.url,
|
|
||||||
filename: fileAttachments.filename,
|
|
||||||
originalName: fileAttachments.originalName,
|
|
||||||
createdAt: fileAttachments.createdAt,
|
|
||||||
})
|
|
||||||
.from(fileAttachments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fileAttachments.targetType, "homework"),
|
|
||||||
eq(fileAttachments.targetId, submissionId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(asc(fileAttachments.createdAt))
|
|
||||||
|
|
||||||
const scans: ScanAttachment[] = rows.map((row, idx) => ({
|
|
||||||
fileId: row.id,
|
|
||||||
url: row.url ?? "",
|
|
||||||
filename: row.filename,
|
|
||||||
originalName: row.originalName,
|
|
||||||
page: idx + 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { success: true, message: "OK", data: scans }
|
return { success: true, message: "OK", data: scans }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -547,8 +566,146 @@ export async function deleteScanAction(
|
|||||||
const { deleteFileAttachment } = await import("@/modules/files/data-access")
|
const { deleteFileAttachment } = await import("@/modules/files/data-access")
|
||||||
await deleteFileAttachment(fileId)
|
await deleteFileAttachment(fileId)
|
||||||
|
|
||||||
|
await trackExamEvent("homework.scan_deleted", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: submissionId,
|
||||||
|
properties: {
|
||||||
|
fileId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return { success: true, message: "已删除", data: null }
|
return { success: true, message: "已删除", data: null }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某作业的优秀提交列表(P3-1:优秀作业展示)。
|
||||||
|
*
|
||||||
|
* 权限:仅教师/管理员可调用(HOMEWORK_GRADE),系统会自动根据
|
||||||
|
* 教师的数据范围(scope)过滤学生提交,避免越权访问。
|
||||||
|
*
|
||||||
|
* @param assignmentId 作业 ID
|
||||||
|
* @param minPercentage 最低得分百分比阈值(0-100,默认 80)
|
||||||
|
* @param limit 返回数量上限(默认 10,最大 50)
|
||||||
|
*/
|
||||||
|
export async function getExcellentSubmissionsAction(params: {
|
||||||
|
assignmentId: string
|
||||||
|
minPercentage?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<ActionState<ExcellentSubmissionItem[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||||
|
|
||||||
|
const data = await getExcellentSubmissions({
|
||||||
|
assignmentId: params.assignmentId,
|
||||||
|
minPercentage: params.minPercentage,
|
||||||
|
limit: params.limit,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
})
|
||||||
|
|
||||||
|
trackExamEvent("homework.excellent_viewed", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: params.assignmentId,
|
||||||
|
properties: {
|
||||||
|
count: data.length,
|
||||||
|
minPercentage: params.minPercentage ?? 80,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, message: "", data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向未提交作业的学生发送催交通知(P3-2:作业催交提醒)。
|
||||||
|
*
|
||||||
|
* 权限:仅教师/管理员可调用(HOMEWORK_GRADE),系统自动根据
|
||||||
|
* 教师的数据范围(scope)过滤学生。
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 查询该作业下未提交的学生列表。
|
||||||
|
* 2. 为每个学生创建站内通知(type=homework, priority=high)。
|
||||||
|
* 3. 返回催交的学生数量。
|
||||||
|
*
|
||||||
|
* 防滥用:同一作业的催交通知会去重(同一学生不重复创建)。
|
||||||
|
*
|
||||||
|
* @param assignmentId 作业 ID
|
||||||
|
*/
|
||||||
|
export async function remindUnsubmittedAction(params: {
|
||||||
|
assignmentId: string
|
||||||
|
}): Promise<ActionState<{ remindedCount: number }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||||
|
|
||||||
|
const unsubmitted = await getUnsubmittedStudents({
|
||||||
|
assignmentId: params.assignmentId,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unsubmitted.length === 0) {
|
||||||
|
return { success: true, message: "", data: { remindedCount: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取作业标题用于通知内容
|
||||||
|
const assignment = await getHomeworkAssignmentById(params.assignmentId, ctx.dataScope)
|
||||||
|
if (!assignment) {
|
||||||
|
return { success: false, message: "Assignment not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentTitle = assignment.title
|
||||||
|
const dueAt = assignment.dueAt
|
||||||
|
? new Date(assignment.dueAt).toLocaleString("zh-CN")
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const { createNotification } = await import("@/modules/notifications/data-access")
|
||||||
|
|
||||||
|
const title = `作业催交提醒:${assignmentTitle}`
|
||||||
|
const content = dueAt
|
||||||
|
? `请尽快完成并提交作业「${assignmentTitle}」,截止时间:${dueAt}。`
|
||||||
|
: `请尽快完成并提交作业「${assignmentTitle}」。`
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
unsubmitted.map((student) =>
|
||||||
|
createNotification({
|
||||||
|
userId: student.studentId,
|
||||||
|
type: "homework",
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
link: `/student/learning/assignments/${params.assignmentId}`,
|
||||||
|
priority: "high",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.status === "fulfilled").length
|
||||||
|
const failedCount = results.length - successCount
|
||||||
|
|
||||||
|
if (failedCount > 0) {
|
||||||
|
console.error(
|
||||||
|
`[remindUnsubmitted] ${failedCount}/${results.length} notifications failed for assignment ${params.assignmentId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackExamEvent("homework.remind_unsubmitted", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: params.assignmentId,
|
||||||
|
properties: {
|
||||||
|
remindedCount: successCount,
|
||||||
|
failedCount,
|
||||||
|
total: results.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `已向 ${successCount} 名学生发送催交通知`,
|
||||||
|
data: { remindedCount: successCount },
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -14,6 +15,7 @@ import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
|||||||
export function AssignmentFilters() {
|
export function AssignmentFilters() {
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
const hasFilters = Boolean(search || status !== "all")
|
const hasFilters = Boolean(search || status !== "all")
|
||||||
|
|
||||||
@@ -29,19 +31,19 @@ export function AssignmentFilters() {
|
|||||||
<FilterSearchInput
|
<FilterSearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => setSearch(v || null)}
|
onChange={(v) => setSearch(v || null)}
|
||||||
placeholder="Search assignments..."
|
placeholder={t("homework.filters.searchPlaceholder")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||||
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
|
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder={t("homework.filters.status")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">{t("homework.filters.allStatus")}</SelectItem>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">{t("homework.filters.statusPending")}</SelectItem>
|
||||||
<SelectItem value="submitted">Submitted</SelectItem>
|
<SelectItem value="submitted">{t("homework.filters.statusSubmitted")}</SelectItem>
|
||||||
<SelectItem value="graded">Graded</SelectItem>
|
<SelectItem value="graded">{t("homework.filters.statusGraded")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
207
src/modules/homework/components/excellent-submissions.tsx
Normal file
207
src/modules/homework/components/excellent-submissions.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { Suspense } from "react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { Award, Trophy, Medal, Clock, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { getExcellentSubmissions } from "../data-access"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P3-1:优秀作业展示
|
||||||
|
*
|
||||||
|
* 在作业详情页向学生展示班级优秀样例,激发学习动力。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 服务端组件,直接调用 data-access,避免多余 action 往返。
|
||||||
|
* - 通过 SectionErrorBoundary + Suspense 实现错误与加载边界。
|
||||||
|
* - 仅展示姓名,不展示学号,保护学生隐私。
|
||||||
|
* - 按得分率降序排列,前三名分别用金/银/铜徽章标识。
|
||||||
|
*
|
||||||
|
* 安全:调用方(页面)必须已通过 requirePermission() 校验,
|
||||||
|
* 本组件接收已过滤的 scope 参数。
|
||||||
|
*/
|
||||||
|
interface ExcellentSubmissionsProps {
|
||||||
|
assignmentId: string
|
||||||
|
/** 最低得分百分比阈值(默认 80) */
|
||||||
|
minPercentage?: number
|
||||||
|
/** 返回数量上限(默认 10) */
|
||||||
|
limit?: number
|
||||||
|
/** 是否以教师视角展示(显示学生姓名;学生视角默认匿名) */
|
||||||
|
revealStudentName?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK_ICONS = [Trophy, Medal, Award] as const
|
||||||
|
const RANK_STYLES = [
|
||||||
|
"bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
|
||||||
|
"bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||||
|
"bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export async function ExcellentSubmissions({
|
||||||
|
assignmentId,
|
||||||
|
minPercentage = 80,
|
||||||
|
limit = 10,
|
||||||
|
revealStudentName = true,
|
||||||
|
}: ExcellentSubmissionsProps): Promise<ReactNode> {
|
||||||
|
const t = await getTranslations("examHomework")
|
||||||
|
|
||||||
|
const items = await getExcellentSubmissions({
|
||||||
|
assignmentId,
|
||||||
|
minPercentage,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Trophy className="h-4 w-4 text-amber-500" />
|
||||||
|
{t("homework.excellent.title")}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("homework.excellent.description", { minPercentage })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Award}
|
||||||
|
title={t("homework.excellent.empty")}
|
||||||
|
description={t("homework.excellent.emptyHint")}
|
||||||
|
className="h-[200px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2" aria-label={t("homework.excellent.title")}>
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const rank = idx + 1
|
||||||
|
const RankIcon = RANK_ICONS[rank - 1] ?? null
|
||||||
|
const rankStyle = RANK_STYLES[rank - 1] ?? "bg-muted text-muted-foreground"
|
||||||
|
const displayName = revealStudentName
|
||||||
|
? item.studentName
|
||||||
|
: t("homework.excellent.studentAnon")
|
||||||
|
const submittedDate = item.submittedAt ? formatDate(item.submittedAt) : ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.submissionId}>
|
||||||
|
<Link
|
||||||
|
href={`/teacher/homework/submissions/${item.submissionId}`}
|
||||||
|
className="flex items-center gap-3 rounded-md border p-3 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{/* 排名徽章 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||||
|
rankStyle
|
||||||
|
)}
|
||||||
|
aria-label={t("homework.excellent.rank", { rank })}
|
||||||
|
>
|
||||||
|
{RankIcon ? (
|
||||||
|
<RankIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-semibold">{rank}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 学生信息 + 提交时间 */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||||
|
{item.isLate && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 shrink-0 gap-1 px-1.5 text-[10px] text-orange-600"
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{t("homework.excellent.lateTag")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{submittedDate && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("homework.excellent.submittedAt", { date: submittedDate })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分数 + 得分率 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-semibold tabular-nums">
|
||||||
|
{t("homework.excellent.scoreValue", {
|
||||||
|
score: item.totalScore,
|
||||||
|
max: item.maxScore,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{t("homework.excellent.percentage", { value: item.percentage })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 骨架屏 —— 数据加载时展示。
|
||||||
|
*/
|
||||||
|
function ExcellentSubmissionsSkeleton(): ReactNode {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="mt-1 h-3 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-md border p-3">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-2 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带边界的优秀作业展示组件。
|
||||||
|
*
|
||||||
|
* 包裹 SectionErrorBoundary + Suspense,防止单个区块错误导致整页崩溃。
|
||||||
|
* 使用示例:
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* <ExcellentSubmissionsWithBoundary assignmentId={id} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ExcellentSubmissionsWithBoundary(
|
||||||
|
props: ExcellentSubmissionsProps
|
||||||
|
): ReactNode {
|
||||||
|
return (
|
||||||
|
<SectionErrorBoundary namespace="examHomework">
|
||||||
|
<Suspense fallback={<ExcellentSubmissionsSkeleton />}>
|
||||||
|
<ExcellentSubmissions {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy"
|
import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy"
|
||||||
@@ -12,10 +13,11 @@ export function HomeworkAssignmentExamContentCard({
|
|||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||||
gradedSampleCount: number
|
gradedSampleCount: number
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("homework.analytics.examContent")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<HomeworkAssignmentExamErrorExplorerLazy
|
<HomeworkAssignmentExamErrorExplorerLazy
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
function ExamErrorExplorerFallback() {
|
function ExamErrorExplorerFallback() {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
|
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
|
||||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Question Preview</span>
|
<span className="text-sm font-medium">{t("homework.analytics.questionPreview")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-6 space-y-6">
|
<div className="flex-1 p-6 space-y-6">
|
||||||
<Skeleton className="h-8 w-[60%]" />
|
<Skeleton className="h-8 w-[60%]" />
|
||||||
@@ -29,7 +31,7 @@ function ExamErrorExplorerFallback() {
|
|||||||
|
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="border-b px-6 py-4">
|
<div className="border-b px-6 py-4">
|
||||||
<div className="text-sm font-medium">Error Analysis</div>
|
<div className="text-sm font-medium">{t("homework.analytics.errorAnalysis")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-6 space-y-6">
|
<div className="flex-1 p-6 space-y-6">
|
||||||
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||||
@@ -41,7 +43,7 @@ function ExamErrorExplorerFallback() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t("homework.analytics.wrongAnswers")}</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-14 w-full rounded-md" />
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
<Skeleton className="h-14 w-full rounded-md" />
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ExamViewer } from "@/modules/exams/components/exam-viewer"
|
import { ExamViewer } from "@/modules/exams/components/exam-viewer"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
|
||||||
export function HomeworkAssignmentExamPreviewPane({
|
export function HomeworkAssignmentExamPreviewPane({
|
||||||
@@ -19,10 +20,11 @@ export function HomeworkAssignmentExamPreviewPane({
|
|||||||
selectedQuestionId: string | null
|
selectedQuestionId: string | null
|
||||||
onQuestionSelect: (questionId: string) => void
|
onQuestionSelect: (questionId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
return (
|
return (
|
||||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Question Preview</span>
|
<span className="text-sm font-medium">{t("homework.analytics.questionPreview")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 bg-background">
|
<ScrollArea className="flex-1 bg-background">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
|
import { getOptions } from "../lib/question-content-utils"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
|
||||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
|
||||||
|
|
||||||
const getOptions = (content: unknown): Array<{ id: string; text: string }> => {
|
|
||||||
if (!isRecord(content)) return []
|
|
||||||
const raw = content.options
|
|
||||||
if (!Array.isArray(raw)) return []
|
|
||||||
const out: Array<{ id: string; text: string }> = []
|
|
||||||
for (const item of raw) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
const id = typeof item.id === "string" ? item.id : ""
|
|
||||||
const text = typeof item.text === "string" ? item.text : ""
|
|
||||||
if (!id || !text) continue
|
|
||||||
out.push({ id, text })
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeInlineJson = (v: unknown) => {
|
const safeInlineJson = (v: unknown) => {
|
||||||
try {
|
try {
|
||||||
const s = JSON.stringify(v)
|
const s = JSON.stringify(v)
|
||||||
@@ -30,26 +16,10 @@ const safeInlineJson = (v: unknown) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
|
|
||||||
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
|
|
||||||
if (answerContent === null || answerContent === undefined) return "未作答"
|
|
||||||
const options = getOptions(question?.questionContent ?? null)
|
|
||||||
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
|
|
||||||
|
|
||||||
if (typeof answerContent === "boolean") return answerContent ? "True" : "False"
|
|
||||||
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
|
|
||||||
if (Array.isArray(answerContent)) {
|
|
||||||
const parts = answerContent
|
|
||||||
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
|
|
||||||
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
|
|
||||||
return parts.join(", ")
|
|
||||||
}
|
|
||||||
return safeInlineJson(answerContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||||
|
|
||||||
function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
|
function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
const pct = clamp01(errorRate) * 100
|
const pct = clamp01(errorRate) * 100
|
||||||
const r = 15.91549430918954
|
const r = 15.91549430918954
|
||||||
const dashA = pct
|
const dashA = pct
|
||||||
@@ -57,7 +27,7 @@ function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
|
|||||||
const showError = pct > 0
|
const showError = pct > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={`错误率 ${pct.toFixed(1)}%`}>
|
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={t("homework.analytics.errorRateAriaLabel", { rate: pct.toFixed(1) })}>
|
||||||
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-border" />
|
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-border" />
|
||||||
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-chart-2" />
|
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-chart-2" />
|
||||||
{showError ? (
|
{showError ? (
|
||||||
@@ -87,14 +57,32 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
|||||||
selected: HomeworkAssignmentQuestionAnalytics | null
|
selected: HomeworkAssignmentQuestionAnalytics | null
|
||||||
gradedSampleCount: number
|
gradedSampleCount: number
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
const wrongAnswers = selected?.wrongAnswers ?? []
|
const wrongAnswers = selected?.wrongAnswers ?? []
|
||||||
const errorCount = selected?.errorCount ?? 0
|
const errorCount = selected?.errorCount ?? 0
|
||||||
const errorRate = selected?.errorRate ?? 0
|
const errorRate = selected?.errorRate ?? 0
|
||||||
|
|
||||||
|
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
|
||||||
|
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
|
||||||
|
if (answerContent === null || answerContent === undefined) return t("homework.analytics.notAnswered")
|
||||||
|
const options = getOptions(question?.questionContent ?? null)
|
||||||
|
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
|
||||||
|
|
||||||
|
if (typeof answerContent === "boolean") return answerContent ? t("homework.take.true") : t("homework.take.false")
|
||||||
|
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
|
||||||
|
if (Array.isArray(answerContent)) {
|
||||||
|
const parts = answerContent
|
||||||
|
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
|
||||||
|
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
|
||||||
|
return parts.join(", ")
|
||||||
|
}
|
||||||
|
return safeInlineJson(answerContent)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="border-b px-6 py-4 bg-muted/5">
|
<div className="border-b px-6 py-4 bg-muted/5">
|
||||||
<div className="text-sm font-medium">Error Analysis</div>
|
<div className="text-sm font-medium">{t("homework.analytics.errorAnalysis")}</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -106,11 +94,11 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 grid gap-1">
|
<div className="min-w-0 flex-1 grid gap-1">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Question</span>
|
<span className="text-muted-foreground">{t("homework.analytics.question")}</span>
|
||||||
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
|
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Errors</span>
|
<span className="text-muted-foreground">{t("homework.analytics.errors")}</span>
|
||||||
<span className="font-medium text-destructive">
|
<span className="font-medium text-destructive">
|
||||||
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
|
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -119,18 +107,18 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t("homework.analytics.wrongAnswersWithCount", { count: wrongAnswers.length })}</div>
|
||||||
{wrongAnswers.length === 0 ? (
|
{wrongAnswers.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
|
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
|
||||||
No wrong answers recorded.
|
{t("homework.analytics.noWrongAnswers")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{wrongAnswers.map((wa) => (
|
{wrongAnswers.map((wa) => (
|
||||||
<div key={wa.studentId} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
<div key={wa.studentId} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
|
<span className="text-xs font-medium text-muted-foreground">{t("homework.analytics.studentAnswer")}</span>
|
||||||
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
|
<span className="text-xs text-muted-foreground">{t("homework.analytics.studentCount", { count: wa.count ?? 1 })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-destructive break-words">
|
<div className="font-medium text-destructive break-words">
|
||||||
{formatAnswer(wa.answerContent, selected)}
|
{formatAnswer(wa.answerContent, selected)}
|
||||||
@@ -143,8 +131,8 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
|
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
|
||||||
<p>Select a question from the left</p>
|
<p>{t("homework.analytics.selectQuestionHint")}</p>
|
||||||
<p className="text-xs mt-1">to view error analysis</p>
|
<p className="text-xs mt-1">{t("homework.analytics.selectQuestionHintDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
|||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||||
gradedSampleCount: number
|
gradedSampleCount: number
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
const data = questions.map((q, index) => ({
|
const data = questions.map((q, index) => ({
|
||||||
name: `Q${index + 1}`,
|
name: `Q${index + 1}`,
|
||||||
errorRate: q.errorRate * 100,
|
errorRate: q.errorRate * 100,
|
||||||
@@ -21,12 +23,12 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
|||||||
return (
|
return (
|
||||||
<Card className="md:col-span-1">
|
<Card className="md:col-span-1">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("homework.analytics.errorRateOverview")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-72">
|
<CardContent className="h-72">
|
||||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
{questions.length === 0 || gradedSampleCount === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
No graded submissions yet.
|
{t("homework.analytics.noGradedSubmissions")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -55,15 +57,15 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
|||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
|
<span className="text-[0.70rem] uppercase text-muted-foreground">{t("homework.analytics.question")}</span>
|
||||||
<span className="font-bold text-muted-foreground">{d.name}</span>
|
<span className="font-bold text-muted-foreground">{d.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
|
<span className="text-[0.70rem] uppercase text-muted-foreground">{t("homework.analytics.errorRateLabel")}</span>
|
||||||
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
|
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
|
<span className="text-[0.70rem] uppercase text-muted-foreground">{t("homework.analytics.errors")}</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{d.errorCount} / {d.total}
|
{d.errorCount} / {d.total}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useTransition } from "react"
|
import { useState, useTransition } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Zap } from "lucide-react"
|
import { Zap } from "lucide-react"
|
||||||
@@ -151,18 +152,18 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
|
|||||||
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<a
|
<Link
|
||||||
href={`/teacher/homework/submissions/${s.id}`}
|
href={`/teacher/homework/submissions/${s.id}`}
|
||||||
className="text-sm underline-offset-4 hover:underline"
|
className="text-sm underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
{t("homework.grade.title")}
|
{t("homework.grade.title")}
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href={`/teacher/homework/submissions/${s.id}/scan-grading`}
|
href={`/teacher/homework/submissions/${s.id}/scan-grading`}
|
||||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline hover:text-foreground"
|
className="text-sm text-muted-foreground underline-offset-4 hover:underline hover:text-foreground"
|
||||||
>
|
>
|
||||||
{t("homework.grade.scanGrading")}
|
{t("homework.grade.scanGrading")}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -29,27 +29,16 @@ import { formatDate } from "@/shared/lib/utils"
|
|||||||
import { QuestionRenderer } from "./question-renderer"
|
import { QuestionRenderer } from "./question-renderer"
|
||||||
import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist"
|
import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist"
|
||||||
import {
|
import {
|
||||||
applyAutoGrades as applyAutoGradesUtil,
|
applyAutoGrades,
|
||||||
extractAnswerValue,
|
extractAnswerValue,
|
||||||
getCorrectnessState as getCorrectnessStateUtil,
|
extractQuestionText,
|
||||||
|
formatStudentAnswer,
|
||||||
|
getCorrectnessState,
|
||||||
getOptions,
|
getOptions,
|
||||||
getTextCorrectAnswers,
|
getTextCorrectAnswers,
|
||||||
isAutoGradable as isAutoGradableUtil,
|
isAutoGradable,
|
||||||
} from "../lib/question-content-utils"
|
} from "../lib/question-content-utils"
|
||||||
|
import type { HomeworkSubmissionAnswerDetails } from "../types"
|
||||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
|
||||||
|
|
||||||
type Answer = {
|
|
||||||
id: string
|
|
||||||
questionId: string
|
|
||||||
questionContent: QuestionContent | null
|
|
||||||
questionType: string
|
|
||||||
maxScore: number
|
|
||||||
studentAnswer: unknown
|
|
||||||
score: number | null
|
|
||||||
feedback: string | null
|
|
||||||
order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type HomeworkGradingViewProps = {
|
type HomeworkGradingViewProps = {
|
||||||
submissionId: string
|
submissionId: string
|
||||||
@@ -58,7 +47,7 @@ type HomeworkGradingViewProps = {
|
|||||||
submittedAt: string | null
|
submittedAt: string | null
|
||||||
status: string
|
status: string
|
||||||
totalScore: number | null
|
totalScore: number | null
|
||||||
answers: Answer[]
|
answers: HomeworkSubmissionAnswerDetails[]
|
||||||
prevSubmissionId?: string | null
|
prevSubmissionId?: string | null
|
||||||
nextSubmissionId?: string | null
|
nextSubmissionId?: string | null
|
||||||
}
|
}
|
||||||
@@ -168,7 +157,7 @@ export function HomeworkGradingView({
|
|||||||
<ScrollArea className="flex-1 p-4 lg:p-8">
|
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||||
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||||
{answers.map((ans, index) => {
|
{answers.map((ans, index) => {
|
||||||
const correctness = getCorrectnessState(ans)
|
const correctness = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
|
||||||
const borderClass =
|
const borderClass =
|
||||||
correctness === "correct"
|
correctness === "correct"
|
||||||
? "border-l-4 border-l-emerald-500"
|
? "border-l-4 border-l-emerald-500"
|
||||||
@@ -193,7 +182,7 @@ export function HomeworkGradingView({
|
|||||||
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
|
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
|
||||||
{ans.score ?? 0} / {ans.maxScore} pts
|
{ans.score ?? 0} / {ans.maxScore} pts
|
||||||
</Badge>
|
</Badge>
|
||||||
{isAutoGradable(ans) && (
|
{isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -345,7 +334,7 @@ export function HomeworkGradingView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Grading Assist (subjective questions only) */}
|
{/* AI Grading Assist (subjective questions only) */}
|
||||||
{!isAutoGradable(ans) && (
|
{!isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
|
||||||
<AiGradingAssist
|
<AiGradingAssist
|
||||||
questionText={extractQuestionText(ans.questionContent)}
|
questionText={extractQuestionText(ans.questionContent)}
|
||||||
questionType={ans.questionType}
|
questionType={ans.questionType}
|
||||||
@@ -430,7 +419,7 @@ export function HomeworkGradingView({
|
|||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{answers.map((ans, i) => {
|
{answers.map((ans, i) => {
|
||||||
const state = getCorrectnessState(ans)
|
const state = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
|
||||||
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
||||||
|
|
||||||
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
@@ -517,46 +506,3 @@ export function HomeworkGradingView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to shared pure functions in lib/question-content-utils
|
|
||||||
// (kept here only as thin wrappers to preserve existing call sites)
|
|
||||||
|
|
||||||
const isAutoGradable = (ans: Answer): boolean =>
|
|
||||||
isAutoGradableUtil({
|
|
||||||
questionType: ans.questionType,
|
|
||||||
questionContent: ans.questionContent,
|
|
||||||
})
|
|
||||||
|
|
||||||
const applyAutoGrades = (incoming: Answer[]): Answer[] =>
|
|
||||||
applyAutoGradesUtil(incoming)
|
|
||||||
|
|
||||||
type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
|
|
||||||
|
|
||||||
const getCorrectnessState = (ans: Answer): CorrectnessState =>
|
|
||||||
getCorrectnessStateUtil({ score: ans.score, maxScore: ans.maxScore })
|
|
||||||
|
|
||||||
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
|
||||||
const v = extractAnswerValue(studentAnswer)
|
|
||||||
if (typeof v === "string") return v
|
|
||||||
if (typeof v === "boolean") return v ? "True" : "False"
|
|
||||||
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
|
|
||||||
if (v == null) return "—"
|
|
||||||
return JSON.stringify(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从题目内容中提取纯文本(用于 AI 批改输入)
|
|
||||||
*
|
|
||||||
* 优先使用 `text` 字段;若不存在则回退到 JSON 字符串,
|
|
||||||
* 保证 AI 服务能拿到可读的题目描述。
|
|
||||||
*/
|
|
||||||
const extractQuestionText = (content: QuestionContent | null): string => {
|
|
||||||
if (!content) return ""
|
|
||||||
if (typeof content.text === "string" && content.text.trim().length > 0) {
|
|
||||||
return content.text
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(content)
|
|
||||||
} catch {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,23 +16,10 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|||||||
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import { gradeHomeworkSubmissionAction, getScansAction, type ScanAttachment } from "../actions"
|
import { gradeHomeworkSubmissionAction, getScansAction } from "../actions"
|
||||||
import { QuestionRenderer } from "./question-renderer"
|
import { QuestionRenderer } from "./question-renderer"
|
||||||
import { ScanImageViewer } from "./scan-image-viewer"
|
import { ScanImageViewer } from "./scan-image-viewer"
|
||||||
|
import type { HomeworkSubmissionAnswerDetails, ScanAttachment } from "../types"
|
||||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
|
||||||
|
|
||||||
type Answer = {
|
|
||||||
id: string
|
|
||||||
questionId: string
|
|
||||||
questionContent: QuestionContent | null
|
|
||||||
questionType: string
|
|
||||||
maxScore: number
|
|
||||||
studentAnswer: unknown
|
|
||||||
score: number | null
|
|
||||||
feedback: string | null
|
|
||||||
order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type HomeworkScanGradingViewProps = {
|
type HomeworkScanGradingViewProps = {
|
||||||
submissionId: string
|
submissionId: string
|
||||||
@@ -41,7 +28,7 @@ type HomeworkScanGradingViewProps = {
|
|||||||
submittedAt: string | null
|
submittedAt: string | null
|
||||||
status: string
|
status: string
|
||||||
totalScore: number | null
|
totalScore: number | null
|
||||||
answers: Answer[]
|
answers: HomeworkSubmissionAnswerDetails[]
|
||||||
prevSubmissionId?: string | null
|
prevSubmissionId?: string | null
|
||||||
nextSubmissionId?: string | null
|
nextSubmissionId?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
type HomeworkTakeConfirmDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
unansweredCount: number
|
||||||
|
isBusy: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生作答页 —— 提交二次确认对话框。
|
||||||
|
*
|
||||||
|
* 根据未作答数量显示不同的确认文案:
|
||||||
|
* - 有未作答:警告"您有 N 道题未作答"
|
||||||
|
* - 全部作答:常规确认"所有题目已作答"
|
||||||
|
*/
|
||||||
|
export function HomeworkTakeConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
unansweredCount,
|
||||||
|
isBusy,
|
||||||
|
onConfirm,
|
||||||
|
}: HomeworkTakeConfirmDialogProps) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{unansweredCount > 0
|
||||||
|
? t("homework.take.unansweredWarning", { count: unansweredCount })
|
||||||
|
: t("homework.take.confirmSubmitDescription")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onOpenChange(false)
|
||||||
|
onConfirm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
src/modules/homework/components/homework-take-sidebar.tsx
Normal file
183
src/modules/homework/components/homework-take-sidebar.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Check, CloudOff, CloudUpload, Loader2, TriangleAlert } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
|
|
||||||
|
type AutoSaveStatus = "idle" | "saving" | "saved" | "error"
|
||||||
|
|
||||||
|
type AnswersByQuestionId = Record<string, { answer: unknown }>
|
||||||
|
|
||||||
|
type HomeworkTakeSidebarProps = {
|
||||||
|
assignment: StudentHomeworkTakeData["assignment"]
|
||||||
|
questions: StudentHomeworkTakeData["questions"]
|
||||||
|
answersByQuestionId: AnswersByQuestionId
|
||||||
|
submissionStatus: string
|
||||||
|
canEdit: boolean
|
||||||
|
isBusy: boolean
|
||||||
|
showQuestions: boolean
|
||||||
|
autoSaveStatus: AutoSaveStatus
|
||||||
|
dueAt: string | null
|
||||||
|
isOverdue: boolean
|
||||||
|
isUrgent: boolean
|
||||||
|
hoursUntilDue: number | null
|
||||||
|
maxAttempts: number
|
||||||
|
attemptsUsed: number
|
||||||
|
attemptsRemaining: number
|
||||||
|
onSubmitClick: () => void
|
||||||
|
onQuestionJump: (questionId: string, index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生作答页 —— 右侧信息侧边栏。
|
||||||
|
*
|
||||||
|
* 展示:自动保存状态、作业状态、截止时间(含逾期/紧急高亮)、
|
||||||
|
* 尝试次数、作业描述、答题进度(题号网格点击跳转)、提交按钮。
|
||||||
|
*/
|
||||||
|
export function HomeworkTakeSidebar({
|
||||||
|
assignment,
|
||||||
|
questions,
|
||||||
|
answersByQuestionId,
|
||||||
|
submissionStatus,
|
||||||
|
canEdit,
|
||||||
|
isBusy,
|
||||||
|
showQuestions,
|
||||||
|
autoSaveStatus,
|
||||||
|
dueAt,
|
||||||
|
isOverdue,
|
||||||
|
isUrgent,
|
||||||
|
hoursUntilDue,
|
||||||
|
maxAttempts,
|
||||||
|
attemptsUsed,
|
||||||
|
attemptsRemaining,
|
||||||
|
onSubmitClick,
|
||||||
|
onQuestionJump,
|
||||||
|
}: HomeworkTakeSidebarProps) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
|
<div className="border-b p-4 bg-muted/30">
|
||||||
|
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||||
|
{autoSaveStatus === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
{autoSaveStatus === "saved" && <Check className="h-3 w-3 text-green-500" />}
|
||||||
|
{autoSaveStatus === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
|
||||||
|
{autoSaveStatus === "idle" && <CloudUpload className="h-3 w-3" />}
|
||||||
|
<span className={
|
||||||
|
autoSaveStatus === "saved" ? "text-green-600" :
|
||||||
|
autoSaveStatus === "error" ? "text-destructive" :
|
||||||
|
"text-muted-foreground"
|
||||||
|
}>
|
||||||
|
{t(`homework.take.autoSave${autoSaveStatus.charAt(0).toUpperCase()}${autoSaveStatus.slice(1)}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
||||||
|
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dueAt && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
|
||||||
|
<div className={cn(
|
||||||
|
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||||
|
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
|
||||||
|
)}>
|
||||||
|
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
|
||||||
|
<span>{formatDate(dueAt)}</span>
|
||||||
|
</div>
|
||||||
|
{isOverdue && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
|
||||||
|
)}
|
||||||
|
{isUrgent && !isOverdue && hoursUntilDue !== null && (
|
||||||
|
<p className="mt-1 text-xs text-orange-500">
|
||||||
|
{hoursUntilDue === 0
|
||||||
|
? t("homework.take.lessThanOneHour")
|
||||||
|
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{maxAttempts > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
|
||||||
|
<div className="mt-1 text-sm">
|
||||||
|
<span className="font-medium">{attemptsUsed}</span>
|
||||||
|
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
|
||||||
|
{attemptsRemaining > 0 && (
|
||||||
|
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{assignment.description || t("homework.take.noDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQuestions && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||||
|
{questions.map((q, i) => {
|
||||||
|
const answer = answersByQuestionId[q.questionId]?.answer
|
||||||
|
const hasAnswer = answer !== undefined &&
|
||||||
|
answer !== "" &&
|
||||||
|
(Array.isArray(answer) ? answer.length > 0 : true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={q.questionId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onQuestionJump(q.questionId, i)}
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||||
|
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
|
||||||
|
)}
|
||||||
|
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
||||||
|
aria-pressed={hasAnswer}
|
||||||
|
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="border-t p-4 bg-muted/20">
|
||||||
|
<Button className="w-full" onClick={onSubmitClick} disabled={isBusy}>
|
||||||
|
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||||
|
{t("homework.take.makeSureAnswered")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,20 +9,9 @@ import { toast } from "sonner"
|
|||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardHeader } from "@/shared/components/ui/card"
|
import { Card, CardHeader } from "@/shared/components/ui/card"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import {
|
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, Camera, Timer } from "lucide-react"
|
||||||
AlertDialog,
|
import { cn } from "@/shared/lib/utils"
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer, Camera } from "lucide-react"
|
|
||||||
import { formatDate, cn } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
import type { StudentHomeworkTakeData } from "../types"
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
|
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
|
||||||
@@ -31,6 +20,8 @@ import { ScanUploader, type ScanImage } from "./scan-uploader"
|
|||||||
import { parseSavedAnswer } from "../lib/question-content-utils"
|
import { parseSavedAnswer } from "../lib/question-content-utils"
|
||||||
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
||||||
import { useExamCountdown } from "../hooks/use-exam-countdown"
|
import { useExamCountdown } from "../hooks/use-exam-countdown"
|
||||||
|
import { HomeworkTakeSidebar } from "./homework-take-sidebar"
|
||||||
|
import { HomeworkTakeConfirmDialog } from "./homework-take-confirm-dialog"
|
||||||
|
|
||||||
type HomeworkTakeViewProps = {
|
type HomeworkTakeViewProps = {
|
||||||
assignmentId: string
|
assignmentId: string
|
||||||
@@ -168,7 +159,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
const handleSaveQuestion = async (questionId: string) => {
|
const handleSaveQuestion = async (questionId: string) => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
// setIsBusy(true) // Don't block UI for individual saves
|
|
||||||
const payload = answersByQuestionId[questionId]?.answer ?? null
|
const payload = answersByQuestionId[questionId]?.answer ?? null
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("submissionId", submissionId)
|
fd.set("submissionId", submissionId)
|
||||||
@@ -177,7 +167,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
const res = await saveHomeworkAnswerAction(null, fd)
|
const res = await saveHomeworkAnswerAction(null, fd)
|
||||||
if (res.success) toast.success(t("homework.take.saved"))
|
if (res.success) toast.success(t("homework.take.saved"))
|
||||||
else toast.error(res.message || t("homework.take.saveFailed"))
|
else toast.error(res.message || t("homework.take.saveFailed"))
|
||||||
// setIsBusy(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -185,14 +174,12 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
try {
|
try {
|
||||||
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
||||||
// flush 失败应中止提交,避免丢失未保存的答案
|
|
||||||
await autoSave.flush()
|
await autoSave.flush()
|
||||||
|
|
||||||
const submitFd = new FormData()
|
const submitFd = new FormData()
|
||||||
submitFd.set("submissionId", submissionId)
|
submitFd.set("submissionId", submissionId)
|
||||||
const submitRes = await submitHomeworkAction(null, submitFd)
|
const submitRes = await submitHomeworkAction(null, submitFd)
|
||||||
if (submitRes.success) {
|
if (submitRes.success) {
|
||||||
// 提交成功后清除离线缓存
|
|
||||||
clearOfflineCache(offlineStorageKey)
|
clearOfflineCache(offlineStorageKey)
|
||||||
toast.success(t("homework.take.submitSuccess"))
|
toast.success(t("homework.take.submitSuccess"))
|
||||||
setSubmissionStatus("submitted")
|
setSubmissionStatus("submitted")
|
||||||
@@ -232,7 +219,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
startedAt: initialData.submission?.startedAt ?? null,
|
startedAt: initialData.submission?.startedAt ?? null,
|
||||||
enabled: isTimedExam,
|
enabled: isTimedExam,
|
||||||
onExpire: () => {
|
onExpire: () => {
|
||||||
// 到时自动提交(仅触发一次)
|
|
||||||
if (submissionStatus === "started" && submissionId) {
|
if (submissionStatus === "started" && submissionId) {
|
||||||
toast.warning(t("homework.take.timeUpAutoSubmit"))
|
toast.warning(t("homework.take.timeUpAutoSubmit"))
|
||||||
void handleSubmit()
|
void handleSubmit()
|
||||||
@@ -249,6 +235,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleQuestionJump = (questionId: string) => {
|
||||||
|
const el = document.getElementById(`question-${questionId}`)
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
@@ -405,153 +396,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<HomeworkTakeSidebar
|
||||||
<div className="border-b p-4 bg-muted/30">
|
assignment={initialData.assignment}
|
||||||
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
questions={initialData.questions}
|
||||||
{canEdit && (
|
answersByQuestionId={answersByQuestionId}
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
|
submissionStatus={submissionStatus}
|
||||||
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
|
canEdit={canEdit}
|
||||||
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
|
isBusy={isBusy}
|
||||||
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
|
showQuestions={showQuestions}
|
||||||
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
|
autoSaveStatus={autoSave.status}
|
||||||
<span className={
|
dueAt={dueAt}
|
||||||
autoSave.status === "saved" ? "text-green-600" :
|
isOverdue={isOverdue}
|
||||||
autoSave.status === "error" ? "text-destructive" :
|
isUrgent={isUrgent}
|
||||||
"text-muted-foreground"
|
hoursUntilDue={hoursUntilDue}
|
||||||
}>
|
maxAttempts={maxAttempts}
|
||||||
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
|
attemptsUsed={attemptsUsed}
|
||||||
</span>
|
attemptsRemaining={attemptsRemaining}
|
||||||
</div>
|
onSubmitClick={() => setShowSubmitConfirm(true)}
|
||||||
)}
|
onQuestionJump={handleQuestionJump}
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1 p-4 overflow-y-auto">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
|
||||||
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dueAt && (
|
<HomeworkTakeConfirmDialog
|
||||||
<div>
|
open={showSubmitConfirm}
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
|
onOpenChange={setShowSubmitConfirm}
|
||||||
<div className={cn(
|
unansweredCount={unansweredCount}
|
||||||
"mt-1 flex items-center gap-2 text-sm font-medium",
|
isBusy={isBusy}
|
||||||
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
|
onConfirm={handleSubmit}
|
||||||
)}>
|
/>
|
||||||
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
|
|
||||||
<span>{formatDate(dueAt)}</span>
|
|
||||||
</div>
|
|
||||||
{isOverdue && (
|
|
||||||
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
|
|
||||||
)}
|
|
||||||
{isUrgent && !isOverdue && hoursUntilDue !== null && (
|
|
||||||
<p className="mt-1 text-xs text-orange-500">
|
|
||||||
{hoursUntilDue === 0
|
|
||||||
? t("homework.take.lessThanOneHour")
|
|
||||||
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{maxAttempts > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
|
|
||||||
<div className="mt-1 text-sm">
|
|
||||||
<span className="font-medium">{attemptsUsed}</span>
|
|
||||||
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
|
|
||||||
{attemptsRemaining > 0 && (
|
|
||||||
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
|
||||||
{initialData.assignment.description || t("homework.take.noDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showQuestions && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
|
|
||||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
|
||||||
{initialData.questions.map((q, i) => {
|
|
||||||
const answer = answersByQuestionId[q.questionId]?.answer
|
|
||||||
const hasAnswer = answer !== undefined &&
|
|
||||||
answer !== "" &&
|
|
||||||
(Array.isArray(answer) ? answer.length > 0 : true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={q.questionId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const el = document.getElementById(`question-${q.questionId}`)
|
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
||||||
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
|
|
||||||
)}
|
|
||||||
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
|
||||||
aria-pressed={hasAnswer}
|
|
||||||
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<div className="border-t p-4 bg-muted/20">
|
|
||||||
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
|
|
||||||
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
|
|
||||||
</Button>
|
|
||||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
|
||||||
{t("homework.take.makeSureAnswered")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提交二次确认对话框 */}
|
|
||||||
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{unansweredCount > 0
|
|
||||||
? t("homework.take.unansweredWarning", { count: unansweredCount })
|
|
||||||
: t("homework.take.confirmSubmitDescription")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setShowSubmitConfirm(false)
|
|
||||||
void handleSubmit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
extractAnswerValue,
|
extractAnswerValue,
|
||||||
getOptions,
|
getOptions,
|
||||||
getQuestionText,
|
getQuestionText,
|
||||||
isRecord,
|
|
||||||
type QuestionOption,
|
type QuestionOption,
|
||||||
type QuestionType,
|
type QuestionType,
|
||||||
} from "../lib/question-content-utils"
|
} from "../lib/question-content-utils"
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目渲染模式
|
* 题目渲染模式
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize2, RotateCw } from "lucide-react"
|
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize2, RotateCw } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
@@ -17,6 +19,7 @@ interface ScanImageViewerProps {
|
|||||||
* 支持翻页、缩放、旋转、全屏。
|
* 支持翻页、缩放、旋转、全屏。
|
||||||
*/
|
*/
|
||||||
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [rotation, setRotation] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
@@ -58,8 +61,8 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={cn("flex h-full items-center justify-center text-muted-foreground", className)}>
|
<div className={cn("flex h-full items-center justify-center text-muted-foreground", className)}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p>该学生未上传答题图片</p>
|
<p>{t("homework.scanViewer.noImages")}</p>
|
||||||
<p className="mt-1 text-xs">学生可在答题页拍摄上传纸质答案</p>
|
<p className="mt-1 text-xs">{t("homework.scanViewer.noImagesHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -80,7 +83,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
disabled={zoom <= 0.5}
|
disabled={zoom <= 0.5}
|
||||||
title="缩小"
|
title={t("homework.scanViewer.zoomOut")}
|
||||||
>
|
>
|
||||||
<ZoomOut className="h-3.5 w-3.5" />
|
<ZoomOut className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -94,7 +97,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
disabled={zoom >= 3}
|
disabled={zoom >= 3}
|
||||||
title="放大"
|
title={t("homework.scanViewer.zoomIn")}
|
||||||
>
|
>
|
||||||
<ZoomIn className="h-3.5 w-3.5" />
|
<ZoomIn className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -104,7 +107,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleRotate}
|
onClick={handleRotate}
|
||||||
title="旋转"
|
title={t("homework.scanViewer.rotate")}
|
||||||
>
|
>
|
||||||
<RotateCw className="h-3.5 w-3.5" />
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,13 +117,13 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleFullscreen}
|
onClick={handleFullscreen}
|
||||||
title="全屏"
|
title={t("homework.scanViewer.fullscreen")}
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
第 {currentPage + 1} / {images.length} 页
|
{t("homework.scanViewer.pageIndicator", { current: currentPage + 1, total: images.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={currentImage.url}
|
src={currentImage.url}
|
||||||
alt={`答题图 第${currentImage.page}页`}
|
alt={t("homework.scanViewer.answerImageAlt", { page: currentImage.page })}
|
||||||
className="max-h-full max-w-full object-contain shadow-lg"
|
className="max-h-full max-w-full object-contain shadow-lg"
|
||||||
style={{ maxHeight: "80vh" }}
|
style={{ maxHeight: "80vh" }}
|
||||||
/>
|
/>
|
||||||
@@ -182,16 +185,17 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToPage(idx)}
|
onClick={() => goToPage(idx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-16 w-12 shrink-0 overflow-hidden rounded border-2 transition-colors",
|
"relative h-16 w-12 overflow-hidden rounded border-2 transition-colors",
|
||||||
idx === currentPage
|
idx === currentPage
|
||||||
? "border-primary"
|
? "border-primary"
|
||||||
: "border-transparent hover:border-muted-foreground/30"
|
: "border-transparent hover:border-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<Image
|
||||||
<img
|
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={`缩略图 ${img.page}`}
|
alt={t("homework.scanViewer.thumbnailAlt", { page: img.page })}
|
||||||
|
fill
|
||||||
|
sizes="48px"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 text-center text-[10px] text-white">
|
<span className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 text-center text-[10px] text-white">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useTransition, type ChangeEvent } from "react"
|
import { useState, useTransition, type ChangeEvent } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Upload, X, Loader2, ImageIcon } from "lucide-react"
|
import { Upload, X, Loader2, ImageIcon } from "lucide-react"
|
||||||
@@ -203,11 +204,12 @@ export function ScanUploader({
|
|||||||
key={img.fileId}
|
key={img.fileId}
|
||||||
className="group relative overflow-hidden rounded-md border bg-muted"
|
className="group relative overflow-hidden rounded-md border bg-muted"
|
||||||
>
|
>
|
||||||
<div className="aspect-[3/4] w-full">
|
<div className="relative aspect-[3/4] w-full">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<Image
|
||||||
<img
|
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={img.filename}
|
alt={img.filename}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
133
src/modules/homework/data-access-exam-cross.ts
Normal file
133
src/modules/homework/data-access-exam-cross.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, count, eq, inArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
homeworkAssignments,
|
||||||
|
homeworkAssignmentTargets,
|
||||||
|
homeworkSubmissions,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
|
||||||
|
*
|
||||||
|
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
|
||||||
|
*/
|
||||||
|
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string | null
|
||||||
|
targetCount: number
|
||||||
|
submittedCount: number
|
||||||
|
gradedCount: number
|
||||||
|
dueAt: string | null
|
||||||
|
}>> => {
|
||||||
|
const assignments = await db.query.homeworkAssignments.findMany({
|
||||||
|
where: eq(homeworkAssignments.sourceExamId, examId),
|
||||||
|
columns: { id: true, title: true, status: true, dueAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignments.length === 0) return []
|
||||||
|
|
||||||
|
const assignmentIds = assignments.map((a) => a.id)
|
||||||
|
|
||||||
|
const [targetsRows, submittedRows, gradedRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||||
|
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||||
|
db
|
||||||
|
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||||
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(homeworkSubmissions.assignmentId),
|
||||||
|
db
|
||||||
|
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(homeworkSubmissions.assignmentId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)]))
|
||||||
|
const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)]))
|
||||||
|
const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)]))
|
||||||
|
|
||||||
|
return assignments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
status: a.status,
|
||||||
|
targetCount: targetMap.get(a.id) ?? 0,
|
||||||
|
submittedCount: submittedMap.get(a.id) ?? 0,
|
||||||
|
gradedCount: gradedMap.get(a.id) ?? 0,
|
||||||
|
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口)
|
||||||
|
*
|
||||||
|
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
|
||||||
|
*/
|
||||||
|
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
|
||||||
|
submissionId: string
|
||||||
|
assignmentId: string
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
score: number
|
||||||
|
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
|
||||||
|
}>> => {
|
||||||
|
const assignments = await db.query.homeworkAssignments.findMany({
|
||||||
|
where: eq(homeworkAssignments.sourceExamId, examId),
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignments.length === 0) return []
|
||||||
|
|
||||||
|
const assignmentIds = assignments.map((a) => a.id)
|
||||||
|
|
||||||
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
student: true,
|
||||||
|
answers: {
|
||||||
|
columns: { questionId: true, score: true, answerContent: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deduplicate: keep only the latest submission per student
|
||||||
|
const latestByStudent = new Map<string, (typeof submissions)[number]>()
|
||||||
|
for (const s of submissions) {
|
||||||
|
if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(latestByStudent.values()).map((s) => ({
|
||||||
|
submissionId: s.id,
|
||||||
|
assignmentId: s.assignmentId,
|
||||||
|
studentId: s.studentId,
|
||||||
|
studentName: s.student.name || "Unknown",
|
||||||
|
score: s.score ?? 0,
|
||||||
|
answers: s.answers.map((a) => ({
|
||||||
|
questionId: a.questionId,
|
||||||
|
score: a.score ?? 0,
|
||||||
|
answerContent: a.answerContent,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
})
|
||||||
49
src/modules/homework/data-access-scans.ts
Normal file
49
src/modules/homework/data-access-scans.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { fileAttachments } from "@/shared/db/schema"
|
||||||
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import type { ScanAttachment } from "./types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业扫描图数据访问层 —— 仅供 homework/actions.ts 调用。
|
||||||
|
*
|
||||||
|
* 扫描图存储在 `fileAttachments` 表中,`targetType="homework"`、
|
||||||
|
* `targetId=submissionId`。权限校验在 Server Action 层完成,
|
||||||
|
* 本模块只负责 DB 查询与映射。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某次提交的所有答题扫描图,按创建时间升序排列。
|
||||||
|
*
|
||||||
|
* @returns `ScanAttachment[]` —— `page` 字段为按顺序生成的页码(从 1 开始)。
|
||||||
|
*/
|
||||||
|
export async function getScansBySubmissionId(
|
||||||
|
submissionId: string
|
||||||
|
): Promise<ScanAttachment[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: fileAttachments.id,
|
||||||
|
url: fileAttachments.url,
|
||||||
|
filename: fileAttachments.filename,
|
||||||
|
originalName: fileAttachments.originalName,
|
||||||
|
createdAt: fileAttachments.createdAt,
|
||||||
|
})
|
||||||
|
.from(fileAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(fileAttachments.targetType, "homework"),
|
||||||
|
eq(fileAttachments.targetId, submissionId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(fileAttachments.createdAt))
|
||||||
|
|
||||||
|
return rows.map((row, idx) => ({
|
||||||
|
fileId: row.id,
|
||||||
|
url: row.url ?? "",
|
||||||
|
filename: row.filename,
|
||||||
|
originalName: row.originalName,
|
||||||
|
page: idx + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
324
src/modules/homework/data-access-student.ts
Normal file
324
src/modules/homework/data-access-student.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
homeworkAnswers,
|
||||||
|
homeworkAssignmentQuestions,
|
||||||
|
homeworkAssignmentTargets,
|
||||||
|
homeworkAssignments,
|
||||||
|
homeworkSubmissions,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import { getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
|
||||||
|
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getHomeworkSubmissionDetails,
|
||||||
|
getAssignmentMaxScoreById,
|
||||||
|
toQuestionContent,
|
||||||
|
toHomeworkSubmissionStatus,
|
||||||
|
} from "./data-access"
|
||||||
|
import type {
|
||||||
|
HomeworkSubmissionDetails,
|
||||||
|
StudentHomeworkAssignmentListItem,
|
||||||
|
StudentHomeworkProgressStatus,
|
||||||
|
StudentHomeworkTakeData,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
||||||
|
if (v === "started") return "in_progress"
|
||||||
|
if (v === "submitted") return "submitted"
|
||||||
|
if (v === "graded") return "graded"
|
||||||
|
return "not_started"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页)
|
||||||
|
*
|
||||||
|
* 查找学生最近一次已提交/已批改的 submission,返回完整详情含答案。
|
||||||
|
*/
|
||||||
|
export const getStudentSubmissionResult = cache(async (
|
||||||
|
assignmentId: string,
|
||||||
|
studentId: string
|
||||||
|
): Promise<HomeworkSubmissionDetails | null> => {
|
||||||
|
const latestSubmission = await db.query.homeworkSubmissions.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||||
|
eq(homeworkSubmissions.studentId, studentId),
|
||||||
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||||
|
),
|
||||||
|
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!latestSubmission) return null
|
||||||
|
|
||||||
|
return getHomeworkSubmissionDetails(latestSubmission.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3-11: 获取学生的考试结果列表(供家长端展示)
|
||||||
|
*
|
||||||
|
* 查找学生所有已批改的、关联到考试的作业提交,
|
||||||
|
* 返回考试标题、科目、分数、提交时间等。
|
||||||
|
*/
|
||||||
|
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
|
||||||
|
submissionId: string
|
||||||
|
examId: string
|
||||||
|
examTitle: string
|
||||||
|
assignmentId: string
|
||||||
|
assignmentTitle: string
|
||||||
|
score: number
|
||||||
|
maxScore: number
|
||||||
|
submittedAt: string | null
|
||||||
|
status: string
|
||||||
|
}>> => {
|
||||||
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkSubmissions.studentId, studentId),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
assignment: {
|
||||||
|
with: { sourceExam: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter to only exam-linked submissions, deduplicate by examId
|
||||||
|
const latestByExamId = new Map<string, (typeof submissions)[number]>()
|
||||||
|
for (const s of submissions) {
|
||||||
|
const examId = s.assignment.sourceExamId
|
||||||
|
if (!examId) continue
|
||||||
|
if (!latestByExamId.has(examId)) latestByExamId.set(examId, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const examIds = Array.from(latestByExamId.keys())
|
||||||
|
if (examIds.length === 0) return []
|
||||||
|
|
||||||
|
// Get max scores for each assignment
|
||||||
|
const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId)
|
||||||
|
const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds)
|
||||||
|
|
||||||
|
return Array.from(latestByExamId.entries()).map(([examId, s]) => ({
|
||||||
|
submissionId: s.id,
|
||||||
|
examId,
|
||||||
|
examTitle: s.assignment.sourceExam?.title ?? s.assignment.title,
|
||||||
|
assignmentId: s.assignmentId,
|
||||||
|
assignmentTitle: s.assignment.title,
|
||||||
|
score: s.score ?? 0,
|
||||||
|
maxScore: maxScoreMap.get(s.assignmentId) ?? 0,
|
||||||
|
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
|
||||||
|
status: s.status ?? "graded",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const targetAssignmentIds = db
|
||||||
|
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(eq(homeworkAssignmentTargets.studentId, studentId))
|
||||||
|
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
id: homeworkAssignments.id,
|
||||||
|
title: homeworkAssignments.title,
|
||||||
|
sourceExamId: homeworkAssignments.sourceExamId,
|
||||||
|
dueAt: homeworkAssignments.dueAt,
|
||||||
|
availableAt: homeworkAssignments.availableAt,
|
||||||
|
maxAttempts: homeworkAssignments.maxAttempts,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(homeworkAssignments.status, "published"),
|
||||||
|
inArray(homeworkAssignments.id, targetAssignmentIds),
|
||||||
|
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
|
||||||
|
|
||||||
|
if (assignments.length === 0) return []
|
||||||
|
|
||||||
|
// Fetch subject names via cross-module interfaces
|
||||||
|
// 快速作业无 sourceExamId,过滤 null 后再查询科目映射
|
||||||
|
const examIds = assignments
|
||||||
|
.map((a) => a.sourceExamId)
|
||||||
|
.filter((id): id is string => id !== null)
|
||||||
|
const [examSubjectIdMap, subjectOptions] = await Promise.all([
|
||||||
|
getExamSubjectIdMap(examIds),
|
||||||
|
getSubjectOptions(),
|
||||||
|
])
|
||||||
|
const subjectNameById = new Map<string, string>()
|
||||||
|
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||||
|
|
||||||
|
const assignmentIds = assignments.map((a) => a.id)
|
||||||
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
|
||||||
|
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const attemptsByAssignmentId = new Map<string, number>()
|
||||||
|
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
||||||
|
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
||||||
|
|
||||||
|
for (const s of submissions) {
|
||||||
|
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
|
||||||
|
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
||||||
|
if (s.status === "submitted" || s.status === "graded") {
|
||||||
|
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments.map((a) => {
|
||||||
|
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
|
||||||
|
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
||||||
|
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
|
||||||
|
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
|
||||||
|
|
||||||
|
const item: StudentHomeworkAssignmentListItem = {
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
subjectName: subjectName ?? null,
|
||||||
|
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||||
|
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||||
|
maxAttempts: a.maxAttempts,
|
||||||
|
attemptsUsed,
|
||||||
|
progressStatus: toStudentProgressStatus(latest?.status),
|
||||||
|
latestSubmissionId: latest?.id ?? null,
|
||||||
|
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
|
||||||
|
latestScore: latest?.score ?? null,
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
|
||||||
|
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||||
|
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
|
||||||
|
})
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||||
|
where: eq(homeworkAssignments.id, assignmentId),
|
||||||
|
})
|
||||||
|
if (!assignment) return null
|
||||||
|
if (assignment.status !== "published") return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
if (assignment.availableAt && assignment.availableAt > now) return null
|
||||||
|
|
||||||
|
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||||
|
eq(homeworkSubmissions.studentId, studentId),
|
||||||
|
eq(homeworkSubmissions.status, "started")
|
||||||
|
),
|
||||||
|
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestSubmission =
|
||||||
|
startedSubmission ??
|
||||||
|
(await db.query.homeworkSubmissions.findFirst({
|
||||||
|
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
|
||||||
|
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||||
|
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||||
|
with: {
|
||||||
|
question: {
|
||||||
|
with: {
|
||||||
|
knowledgePoints: {
|
||||||
|
with: {
|
||||||
|
knowledgePoint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: (q, { asc }) => [asc(q.order)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
|
||||||
|
if (latestSubmission) {
|
||||||
|
const answers = await db.query.homeworkAnswers.findMany({
|
||||||
|
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
||||||
|
})
|
||||||
|
for (const ans of answers) {
|
||||||
|
answersByQuestionId.set(ans.questionId, {
|
||||||
|
answer: ans.answerContent,
|
||||||
|
score: ans.score,
|
||||||
|
feedback: ans.feedback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
|
||||||
|
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
|
||||||
|
if (assignment.sourceExamId) {
|
||||||
|
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
|
||||||
|
if (examConfig) {
|
||||||
|
examModeConfig = {
|
||||||
|
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
|
||||||
|
? examConfig.examMode
|
||||||
|
: "homework",
|
||||||
|
durationMinutes: examConfig.durationMinutes,
|
||||||
|
shuffleQuestions: examConfig.shuffleQuestions ?? false,
|
||||||
|
allowLateStart: examConfig.allowLateStart ?? false,
|
||||||
|
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
|
||||||
|
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignment: {
|
||||||
|
id: assignment.id,
|
||||||
|
title: assignment.title,
|
||||||
|
description: assignment.description,
|
||||||
|
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||||
|
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||||
|
allowLate: assignment.allowLate,
|
||||||
|
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||||
|
maxAttempts: assignment.maxAttempts,
|
||||||
|
},
|
||||||
|
examModeConfig,
|
||||||
|
submission: latestSubmission
|
||||||
|
? {
|
||||||
|
id: latestSubmission.id,
|
||||||
|
status: toHomeworkSubmissionStatus(latestSubmission.status),
|
||||||
|
attemptNo: latestSubmission.attemptNo,
|
||||||
|
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
|
||||||
|
score: latestSubmission.score ?? null,
|
||||||
|
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
questions: assignmentQuestions.map((aq) => {
|
||||||
|
const saved = answersByQuestionId.get(aq.questionId)
|
||||||
|
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
|
||||||
|
const kps = aq.question.knowledgePoints ?? []
|
||||||
|
return {
|
||||||
|
questionId: aq.questionId,
|
||||||
|
questionType: aq.question.type,
|
||||||
|
questionContent: toQuestionContent(aq.question.content),
|
||||||
|
maxScore: aq.score ?? 0,
|
||||||
|
order: aq.order ?? 0,
|
||||||
|
savedAnswer: saved?.answer ?? null,
|
||||||
|
score: saved?.score ?? null,
|
||||||
|
feedback: saved?.feedback ?? null,
|
||||||
|
knowledgePoints: kps.map((kp) => ({
|
||||||
|
id: kp.knowledgePoint.id,
|
||||||
|
name: kp.knowledgePoint.name,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
9
src/modules/homework/data-access-utils.ts
Normal file
9
src/modules/homework/data-access-utils.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨模块工具函数导出层
|
||||||
|
*
|
||||||
|
* 将 homework 模块的纯工具函数通过 data-access 接口暴露给其他模块,
|
||||||
|
* 避免跨模块直接 import lib/ 目录,符合「modules/ 之间通过对方 data-access 通信」规则。
|
||||||
|
*/
|
||||||
|
export { getQuestionText } from "./lib/question-content-utils"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
homeworkAssignments,
|
homeworkAssignments,
|
||||||
homeworkSubmissions,
|
homeworkSubmissions,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||||
import { getExamIdsByGradeIds, getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
|
import { getExamIdsByGradeIds } from "@/modules/exams/data-access"
|
||||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
HomeworkAssignmentListItem,
|
HomeworkAssignmentListItem,
|
||||||
@@ -23,14 +23,10 @@ import type {
|
|||||||
HomeworkSubmissionDetails,
|
HomeworkSubmissionDetails,
|
||||||
HomeworkSubmissionListItem,
|
HomeworkSubmissionListItem,
|
||||||
HomeworkSubmissionStatus,
|
HomeworkSubmissionStatus,
|
||||||
StudentHomeworkAssignmentListItem,
|
ExcellentSubmissionItem,
|
||||||
StudentHomeworkProgressStatus,
|
|
||||||
StudentHomeworkTakeData,
|
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
|
||||||
|
|
||||||
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
|
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
|
||||||
v === "draft" || v === "published" || v === "archived"
|
v === "draft" || v === "published" || v === "archived"
|
||||||
|
|
||||||
@@ -40,7 +36,7 @@ const toHomeworkAssignmentStatus = (v: string | null | undefined): HomeworkAssig
|
|||||||
const isHomeworkSubmissionStatus = (v: unknown): v is HomeworkSubmissionStatus =>
|
const isHomeworkSubmissionStatus = (v: unknown): v is HomeworkSubmissionStatus =>
|
||||||
v === "started" || v === "submitted" || v === "graded"
|
v === "started" || v === "submitted" || v === "graded"
|
||||||
|
|
||||||
const toHomeworkSubmissionStatus = (v: string | null | undefined): HomeworkSubmissionStatus =>
|
export const toHomeworkSubmissionStatus = (v: string | null | undefined): HomeworkSubmissionStatus =>
|
||||||
isHomeworkSubmissionStatus(v) ? v : "started"
|
isHomeworkSubmissionStatus(v) ? v : "started"
|
||||||
|
|
||||||
const isHomeworkQuestionContent = (v: unknown): v is HomeworkQuestionContent =>
|
const isHomeworkQuestionContent = (v: unknown): v is HomeworkQuestionContent =>
|
||||||
@@ -495,128 +491,6 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
|
|
||||||
*
|
|
||||||
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
|
|
||||||
*/
|
|
||||||
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
status: string | null
|
|
||||||
targetCount: number
|
|
||||||
submittedCount: number
|
|
||||||
gradedCount: number
|
|
||||||
dueAt: string | null
|
|
||||||
}>> => {
|
|
||||||
const assignments = await db.query.homeworkAssignments.findMany({
|
|
||||||
where: eq(homeworkAssignments.sourceExamId, examId),
|
|
||||||
columns: { id: true, title: true, status: true, dueAt: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (assignments.length === 0) return []
|
|
||||||
|
|
||||||
const assignmentIds = assignments.map((a) => a.id)
|
|
||||||
|
|
||||||
const [targetsRows, submittedRows, gradedRows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() })
|
|
||||||
.from(homeworkAssignmentTargets)
|
|
||||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
|
||||||
.groupBy(homeworkAssignmentTargets.assignmentId),
|
|
||||||
db
|
|
||||||
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
|
||||||
.from(homeworkSubmissions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
|
||||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(homeworkSubmissions.assignmentId),
|
|
||||||
db
|
|
||||||
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
|
||||||
.from(homeworkSubmissions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(homeworkSubmissions.assignmentId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)]))
|
|
||||||
const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)]))
|
|
||||||
const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)]))
|
|
||||||
|
|
||||||
return assignments.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
title: a.title,
|
|
||||||
status: a.status,
|
|
||||||
targetCount: targetMap.get(a.id) ?? 0,
|
|
||||||
submittedCount: submittedMap.get(a.id) ?? 0,
|
|
||||||
gradedCount: gradedMap.get(a.id) ?? 0,
|
|
||||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口)
|
|
||||||
*
|
|
||||||
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
|
|
||||||
*/
|
|
||||||
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
|
|
||||||
submissionId: string
|
|
||||||
assignmentId: string
|
|
||||||
studentId: string
|
|
||||||
studentName: string
|
|
||||||
score: number
|
|
||||||
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
|
|
||||||
}>> => {
|
|
||||||
const assignments = await db.query.homeworkAssignments.findMany({
|
|
||||||
where: eq(homeworkAssignments.sourceExamId, examId),
|
|
||||||
columns: { id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (assignments.length === 0) return []
|
|
||||||
|
|
||||||
const assignmentIds = assignments.map((a) => a.id)
|
|
||||||
|
|
||||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
|
||||||
where: and(
|
|
||||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
),
|
|
||||||
with: {
|
|
||||||
student: true,
|
|
||||||
answers: {
|
|
||||||
columns: { questionId: true, score: true, answerContent: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Deduplicate: keep only the latest submission per student
|
|
||||||
const latestByStudent = new Map<string, (typeof submissions)[number]>()
|
|
||||||
for (const s of submissions) {
|
|
||||||
if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(latestByStudent.values()).map((s) => ({
|
|
||||||
submissionId: s.id,
|
|
||||||
assignmentId: s.assignmentId,
|
|
||||||
studentId: s.studentId,
|
|
||||||
studentName: s.student.name || "Unknown",
|
|
||||||
score: s.score ?? 0,
|
|
||||||
answers: s.answers.map((a) => ({
|
|
||||||
questionId: a.questionId,
|
|
||||||
score: a.score ?? 0,
|
|
||||||
answerContent: a.answerContent,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
||||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||||
where: eq(homeworkSubmissions.id, submissionId),
|
where: eq(homeworkSubmissions.id, submissionId),
|
||||||
@@ -702,307 +576,139 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页)
|
* 查询某作业下的优秀提交(P3-1:优秀作业展示)。
|
||||||
*
|
*
|
||||||
* 查找学生最近一次已提交/已批改的 submission,返回完整详情含答案。
|
* 评分规则:
|
||||||
|
* - 仅返回 `status = "graded"` 且 `score IS NOT NULL` 的提交。
|
||||||
|
* - 同一学生多次提交时,仅保留最高分(避免重复展示)。
|
||||||
|
* - 按得分百分比 `score / maxScore` 降序排列。
|
||||||
|
* - 过滤出百分比 ≥ `minPercentage`(默认 80)的提交。
|
||||||
|
*
|
||||||
|
* 权限说明:调用方必须在外层通过 `requirePermission()` 校验,
|
||||||
|
* 并通过 `scope` 参数传入数据范围(教师仅可见自己班级的学生提交)。
|
||||||
*/
|
*/
|
||||||
export const getStudentSubmissionResult = cache(async (
|
export const getExcellentSubmissions = cache(async (params: {
|
||||||
assignmentId: string,
|
assignmentId: string
|
||||||
studentId: string
|
minPercentage?: number
|
||||||
): Promise<HomeworkSubmissionDetails | null> => {
|
limit?: number
|
||||||
const latestSubmission = await db.query.homeworkSubmissions.findFirst({
|
scope?: DataScope
|
||||||
where: and(
|
}): Promise<ExcellentSubmissionItem[]> => {
|
||||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
const minPct = params.minPercentage ?? 80
|
||||||
eq(homeworkSubmissions.studentId, studentId),
|
const limit = Math.max(1, Math.min(50, params.limit ?? 10))
|
||||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
|
||||||
),
|
// 1. 拉取该作业所有已批改提交
|
||||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
const conditions = [
|
||||||
columns: { id: true },
|
eq(homeworkSubmissions.assignmentId, params.assignmentId),
|
||||||
|
eq(homeworkSubmissions.status, "graded"),
|
||||||
|
sql`${homeworkSubmissions.score} IS NOT NULL`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (params.scope) {
|
||||||
|
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||||
|
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
|
||||||
|
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
|
||||||
|
} else if (params.scope.type === "owned") {
|
||||||
|
const creatorAssignmentIds = db
|
||||||
|
.select({ assignmentId: homeworkAssignments.id })
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.where(eq(homeworkAssignments.creatorId, params.scope.userId))
|
||||||
|
|
||||||
|
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
|
||||||
|
}
|
||||||
|
// grade_managed / all 不额外过滤(依赖 assignment 维度即可)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
with: {
|
||||||
|
student: true,
|
||||||
|
assignment: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!latestSubmission) return null
|
// 2. 计算总分阈值(来自作业题目分数之和)
|
||||||
|
const maxScoreRows = await db
|
||||||
|
.select({
|
||||||
|
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentQuestions)
|
||||||
|
.where(eq(homeworkAssignmentQuestions.assignmentId, params.assignmentId))
|
||||||
|
|
||||||
return getHomeworkSubmissionDetails(latestSubmission.id)
|
const maxScore = Number(maxScoreRows[0]?.maxScore ?? 0)
|
||||||
|
|
||||||
|
// 3. 同一学生取最高分
|
||||||
|
const bestByStudent = new Map<string, ExcellentSubmissionItem>()
|
||||||
|
for (const s of submissions) {
|
||||||
|
if (s.score === null) continue
|
||||||
|
const percentage = maxScore > 0 ? Math.round((s.score / maxScore) * 1000) / 10 : 0
|
||||||
|
if (percentage < minPct) continue
|
||||||
|
|
||||||
|
const existing = bestByStudent.get(s.studentId)
|
||||||
|
if (existing && existing.percentage >= percentage) continue
|
||||||
|
|
||||||
|
bestByStudent.set(s.studentId, {
|
||||||
|
submissionId: s.id,
|
||||||
|
assignmentId: s.assignmentId,
|
||||||
|
assignmentTitle: s.assignment.title,
|
||||||
|
studentName: s.student.name || "Unknown",
|
||||||
|
totalScore: s.score,
|
||||||
|
maxScore,
|
||||||
|
percentage,
|
||||||
|
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : "",
|
||||||
|
isLate: s.isLate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 排序 + 截断
|
||||||
|
return Array.from(bestByStudent.values())
|
||||||
|
.sort((a, b) => b.percentage - a.percentage)
|
||||||
|
.slice(0, limit)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V3-11: 获取学生的考试结果列表(供家长端展示)
|
* 查询某作业下尚未提交(或未开始)的学生列表(P3-2:作业催交提醒)。
|
||||||
*
|
*
|
||||||
* 查找学生所有已批改的、关联到考试的作业提交,
|
* 逻辑:
|
||||||
* 返回考试标题、科目、分数、提交时间等。
|
* - 从 `homeworkAssignmentTargets` 获取作业目标学生。
|
||||||
|
* - 排除已有 `submitted` 或 `graded` 状态提交的学生。
|
||||||
|
* - 返回学生 ID + 姓名,用于发送催交通知。
|
||||||
|
*
|
||||||
|
* 权限:调用方必须通过 `requirePermission()` 校验。
|
||||||
*/
|
*/
|
||||||
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
|
export const getUnsubmittedStudents = cache(async (params: {
|
||||||
submissionId: string
|
|
||||||
examId: string
|
|
||||||
examTitle: string
|
|
||||||
assignmentId: string
|
assignmentId: string
|
||||||
assignmentTitle: string
|
scope?: DataScope
|
||||||
score: number
|
}): Promise<Array<{ studentId: string; studentName: string }>> => {
|
||||||
maxScore: number
|
// 1. 获取作业目标学生
|
||||||
submittedAt: string | null
|
const targets = await db.query.homeworkAssignmentTargets.findMany({
|
||||||
status: string
|
where: eq(homeworkAssignmentTargets.assignmentId, params.assignmentId),
|
||||||
}>> => {
|
|
||||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(homeworkSubmissions.studentId, studentId),
|
|
||||||
eq(homeworkSubmissions.status, "graded")
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
assignment: {
|
student: true,
|
||||||
with: { sourceExam: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
|
||||||
limit: 50,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter to only exam-linked submissions, deduplicate by examId
|
if (targets.length === 0) return []
|
||||||
const latestByExamId = new Map<string, (typeof submissions)[number]>()
|
|
||||||
for (const s of submissions) {
|
|
||||||
const examId = s.assignment.sourceExamId
|
|
||||||
if (!examId) continue
|
|
||||||
if (!latestByExamId.has(examId)) latestByExamId.set(examId, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
const examIds = Array.from(latestByExamId.keys())
|
// 2. 获取已提交的学生 ID(submitted 或 graded 状态)
|
||||||
if (examIds.length === 0) return []
|
const submitted = await db.query.homeworkSubmissions.findMany({
|
||||||
|
|
||||||
// Get max scores for each assignment
|
|
||||||
const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId)
|
|
||||||
const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds)
|
|
||||||
|
|
||||||
return Array.from(latestByExamId.entries()).map(([examId, s]) => ({
|
|
||||||
submissionId: s.id,
|
|
||||||
examId,
|
|
||||||
examTitle: s.assignment.sourceExam?.title ?? s.assignment.title,
|
|
||||||
assignmentId: s.assignmentId,
|
|
||||||
assignmentTitle: s.assignment.title,
|
|
||||||
score: s.score ?? 0,
|
|
||||||
maxScore: maxScoreMap.get(s.assignmentId) ?? 0,
|
|
||||||
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
|
|
||||||
status: s.status ?? "graded",
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
|
||||||
if (v === "started") return "in_progress"
|
|
||||||
if (v === "submitted") return "submitted"
|
|
||||||
if (v === "graded") return "graded"
|
|
||||||
return "not_started"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
const targetAssignmentIds = db
|
|
||||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
|
||||||
.from(homeworkAssignmentTargets)
|
|
||||||
.where(eq(homeworkAssignmentTargets.studentId, studentId))
|
|
||||||
|
|
||||||
const assignments = await db
|
|
||||||
.select({
|
|
||||||
id: homeworkAssignments.id,
|
|
||||||
title: homeworkAssignments.title,
|
|
||||||
sourceExamId: homeworkAssignments.sourceExamId,
|
|
||||||
dueAt: homeworkAssignments.dueAt,
|
|
||||||
availableAt: homeworkAssignments.availableAt,
|
|
||||||
maxAttempts: homeworkAssignments.maxAttempts,
|
|
||||||
createdAt: homeworkAssignments.createdAt,
|
|
||||||
})
|
|
||||||
.from(homeworkAssignments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(homeworkAssignments.status, "published"),
|
|
||||||
inArray(homeworkAssignments.id, targetAssignmentIds),
|
|
||||||
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
|
|
||||||
|
|
||||||
if (assignments.length === 0) return []
|
|
||||||
|
|
||||||
// Fetch subject names via cross-module interfaces
|
|
||||||
// 快速作业无 sourceExamId,过滤 null 后再查询科目映射
|
|
||||||
const examIds = assignments
|
|
||||||
.map((a) => a.sourceExamId)
|
|
||||||
.filter((id): id is string => id !== null)
|
|
||||||
const [examSubjectIdMap, subjectOptions] = await Promise.all([
|
|
||||||
getExamSubjectIdMap(examIds),
|
|
||||||
getSubjectOptions(),
|
|
||||||
])
|
|
||||||
const subjectNameById = new Map<string, string>()
|
|
||||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
|
||||||
|
|
||||||
const assignmentIds = assignments.map((a) => a.id)
|
|
||||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
|
||||||
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
|
|
||||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
|
||||||
})
|
|
||||||
|
|
||||||
const attemptsByAssignmentId = new Map<string, number>()
|
|
||||||
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
|
||||||
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
|
||||||
|
|
||||||
for (const s of submissions) {
|
|
||||||
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
|
|
||||||
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
|
||||||
if (s.status === "submitted" || s.status === "graded") {
|
|
||||||
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return assignments.map((a) => {
|
|
||||||
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
|
|
||||||
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
|
||||||
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
|
|
||||||
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
|
|
||||||
|
|
||||||
const item: StudentHomeworkAssignmentListItem = {
|
|
||||||
id: a.id,
|
|
||||||
title: a.title,
|
|
||||||
subjectName: subjectName ?? null,
|
|
||||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
|
||||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
|
||||||
maxAttempts: a.maxAttempts,
|
|
||||||
attemptsUsed,
|
|
||||||
progressStatus: toStudentProgressStatus(latest?.status),
|
|
||||||
latestSubmissionId: latest?.id ?? null,
|
|
||||||
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
|
|
||||||
latestScore: latest?.score ?? null,
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
|
|
||||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
|
||||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
|
|
||||||
})
|
|
||||||
if (!target) return null
|
|
||||||
|
|
||||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
|
||||||
where: eq(homeworkAssignments.id, assignmentId),
|
|
||||||
})
|
|
||||||
if (!assignment) return null
|
|
||||||
if (assignment.status !== "published") return null
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
if (assignment.availableAt && assignment.availableAt > now) return null
|
|
||||||
|
|
||||||
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
|
|
||||||
where: and(
|
where: and(
|
||||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
eq(homeworkSubmissions.assignmentId, params.assignmentId),
|
||||||
eq(homeworkSubmissions.studentId, studentId),
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||||
eq(homeworkSubmissions.status, "started")
|
|
||||||
),
|
),
|
||||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
columns: { studentId: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const latestSubmission =
|
const submittedIds = new Set(submitted.map((s) => s.studentId))
|
||||||
startedSubmission ??
|
|
||||||
(await db.query.homeworkSubmissions.findFirst({
|
// 3. 过滤出未提交的学生
|
||||||
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
|
const unsubmitted = targets
|
||||||
orderBy: (s, { desc }) => [desc(s.createdAt)],
|
.filter((t) => !submittedIds.has(t.studentId))
|
||||||
|
.map((t) => ({
|
||||||
|
studentId: t.studentId,
|
||||||
|
studentName: t.student.name || "Unknown",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
return unsubmitted
|
||||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
|
||||||
with: {
|
|
||||||
question: {
|
|
||||||
with: {
|
|
||||||
knowledgePoints: {
|
|
||||||
with: {
|
|
||||||
knowledgePoint: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: (q, { asc }) => [asc(q.order)],
|
|
||||||
})
|
|
||||||
|
|
||||||
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
|
|
||||||
if (latestSubmission) {
|
|
||||||
const answers = await db.query.homeworkAnswers.findMany({
|
|
||||||
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
|
||||||
})
|
|
||||||
for (const ans of answers) {
|
|
||||||
answersByQuestionId.set(ans.questionId, {
|
|
||||||
answer: ans.answerContent,
|
|
||||||
score: ans.score,
|
|
||||||
feedback: ans.feedback,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
|
|
||||||
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
|
|
||||||
if (assignment.sourceExamId) {
|
|
||||||
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
|
|
||||||
if (examConfig) {
|
|
||||||
examModeConfig = {
|
|
||||||
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
|
|
||||||
? examConfig.examMode
|
|
||||||
: "homework",
|
|
||||||
durationMinutes: examConfig.durationMinutes,
|
|
||||||
shuffleQuestions: examConfig.shuffleQuestions ?? false,
|
|
||||||
allowLateStart: examConfig.allowLateStart ?? false,
|
|
||||||
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
|
|
||||||
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
assignment: {
|
|
||||||
id: assignment.id,
|
|
||||||
title: assignment.title,
|
|
||||||
description: assignment.description,
|
|
||||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
|
||||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
|
||||||
allowLate: assignment.allowLate,
|
|
||||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
|
||||||
maxAttempts: assignment.maxAttempts,
|
|
||||||
},
|
|
||||||
examModeConfig,
|
|
||||||
submission: latestSubmission
|
|
||||||
? {
|
|
||||||
id: latestSubmission.id,
|
|
||||||
status: toHomeworkSubmissionStatus(latestSubmission.status),
|
|
||||||
attemptNo: latestSubmission.attemptNo,
|
|
||||||
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
|
|
||||||
score: latestSubmission.score ?? null,
|
|
||||||
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
questions: assignmentQuestions.map((aq) => {
|
|
||||||
const saved = answersByQuestionId.get(aq.questionId)
|
|
||||||
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
|
|
||||||
const kps = aq.question.knowledgePoints ?? []
|
|
||||||
return {
|
|
||||||
questionId: aq.questionId,
|
|
||||||
questionType: aq.question.type,
|
|
||||||
questionContent: toQuestionContent(aq.question.content),
|
|
||||||
maxScore: aq.score ?? 0,
|
|
||||||
order: aq.order ?? 0,
|
|
||||||
savedAnswer: saved?.answer ?? null,
|
|
||||||
score: saved?.score ?? null,
|
|
||||||
feedback: saved?.feedback ?? null,
|
|
||||||
knowledgePoints: kps.map((kp) => ({
|
|
||||||
id: kp.knowledgePoint.id,
|
|
||||||
name: kp.knowledgePoint.name,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-export stats functions for backward compatibility
|
|
||||||
// New code should import directly from "./stats-service"
|
|
||||||
export {
|
|
||||||
getTeacherGradeTrends,
|
|
||||||
getHomeworkAssignmentAnalytics,
|
|
||||||
getStudentDashboardGrades,
|
|
||||||
getHomeworkDashboardStats,
|
|
||||||
} from "./stats-service"
|
|
||||||
export type { HomeworkDashboardStats } from "./stats-service"
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
import {
|
import {
|
||||||
isRecord,
|
|
||||||
getQuestionText,
|
getQuestionText,
|
||||||
getOptions,
|
getOptions,
|
||||||
getChoiceCorrectIds,
|
getChoiceCorrectIds,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目内容解析纯函数
|
* 题目内容解析纯函数
|
||||||
*
|
*
|
||||||
@@ -11,14 +13,28 @@ export type QuestionOption = {
|
|||||||
isCorrect?: boolean
|
isCorrect?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
||||||
typeof v === "object" && v !== null
|
|
||||||
|
|
||||||
export const getQuestionText = (content: unknown): string => {
|
export const getQuestionText = (content: unknown): string => {
|
||||||
if (!isRecord(content)) return ""
|
if (!isRecord(content)) return ""
|
||||||
return typeof content.text === "string" ? content.text : ""
|
return typeof content.text === "string" ? content.text : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从题目内容中提取纯文本(用于 AI 批改输入)。
|
||||||
|
*
|
||||||
|
* 优先使用 `text` 字段;若不存在则回退到 JSON 字符串,
|
||||||
|
* 保证 AI 服务能拿到可读的题目描述。
|
||||||
|
*/
|
||||||
|
export const extractQuestionText = (content: unknown): string => {
|
||||||
|
const text = getQuestionText(content)
|
||||||
|
if (text.trim().length > 0) return text
|
||||||
|
if (!isRecord(content)) return ""
|
||||||
|
try {
|
||||||
|
return JSON.stringify(content)
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getOptions = (content: unknown): QuestionOption[] => {
|
export const getOptions = (content: unknown): QuestionOption[] => {
|
||||||
if (!isRecord(content)) return []
|
if (!isRecord(content)) return []
|
||||||
const raw = content.options
|
const raw = content.options
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod 校验错误消息使用 i18n 键(参考 `auth/schema.ts` 模式)。
|
||||||
|
*
|
||||||
|
* Server Action 在校验失败时调用 `getTranslations("examHomework")` 翻译这些键
|
||||||
|
* 后再返回 `fieldErrors`。键定义于 `messages/{locale}/exam-homework.json`
|
||||||
|
* 的 `homework.form.error.*` 命名空间下。
|
||||||
|
*/
|
||||||
const dateStringSchema = z
|
const dateStringSchema = z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format")
|
.refine((v) => !Number.isNaN(new Date(v).getTime()), "homework.form.error.invalidDate")
|
||||||
|
|
||||||
export const CreateHomeworkAssignmentSchema = z
|
export const CreateHomeworkAssignmentSchema = z
|
||||||
.object({
|
.object({
|
||||||
sourceExamId: z.string().optional(),
|
sourceExamId: z.string().optional(),
|
||||||
classId: z.string().min(1),
|
classId: z.string().min(1),
|
||||||
title: z.string().min(1, "Title is required for quick assignments"),
|
title: z.string().min(1, "homework.form.error.titleRequired"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
availableAt: dateStringSchema.optional(),
|
availableAt: dateStringSchema.optional(),
|
||||||
dueAt: dateStringSchema.optional(),
|
dueAt: dateStringSchema.optional(),
|
||||||
@@ -28,21 +35,21 @@ export const CreateHomeworkAssignmentSchema = z
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["dueAt"],
|
path: ["dueAt"],
|
||||||
message: "截止时间必须晚于可用时间",
|
message: "homework.form.error.dueAfterAvailable",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (due !== null && lateDue !== null && due > lateDue) {
|
if (due !== null && lateDue !== null && due > lateDue) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["lateDueAt"],
|
path: ["lateDueAt"],
|
||||||
message: "迟交截止时间必须晚于正常截止时间",
|
message: "homework.form.error.lateDueAfterDue",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (data.allowLate && !data.lateDueAt) {
|
if (data.allowLate && !data.lateDueAt) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["lateDueAt"],
|
path: ["lateDueAt"],
|
||||||
message: "允许迟交时必须设置迟交截止时间",
|
message: "homework.form.error.lateDueRequired",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import type {
|
|||||||
TeacherGradeTrendItem,
|
TeacherGradeTrendItem,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access"
|
import { getAssignmentMaxScoreById, toQuestionContent } from "./data-access"
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
|
|
||||||
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
|
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
|
||||||
v === "draft" || v === "published" || v === "archived"
|
v === "draft" || v === "published" || v === "archived"
|
||||||
|
|||||||
@@ -245,3 +245,53 @@ export interface StudentDashboardGradeProps {
|
|||||||
recent: StudentHomeworkScoreAnalytics[]
|
recent: StudentHomeworkScoreAnalytics[]
|
||||||
ranking: StudentRanking | null
|
ranking: StudentRanking | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优秀作业展示项 —— 用于教师在作业详情页向学生展示班级优秀样例。
|
||||||
|
*
|
||||||
|
* 字段说明:
|
||||||
|
* - `studentName` 仅展示姓名,不含 ID,保护隐私。
|
||||||
|
* - `percentage` 用于排序与展示(0-100)。
|
||||||
|
* - `isLate` 用于标注迟交但质量优秀的样例。
|
||||||
|
* - `totalScore` / `maxScore` 用于展示实际分数。
|
||||||
|
*/
|
||||||
|
export interface ExcellentSubmissionItem {
|
||||||
|
submissionId: string
|
||||||
|
assignmentId: string
|
||||||
|
assignmentTitle: string
|
||||||
|
studentName: string
|
||||||
|
totalScore: number
|
||||||
|
maxScore: number
|
||||||
|
percentage: number
|
||||||
|
submittedAt: string
|
||||||
|
isLate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优秀作业展示查询参数。
|
||||||
|
*
|
||||||
|
* - `assignmentId` 指定作业 ID(必填,按作业维度展示)。
|
||||||
|
* - `minPercentage` 最低得分百分比阈值(默认 80,即 80%)。
|
||||||
|
* - `limit` 返回数量上限(默认 10)。
|
||||||
|
*/
|
||||||
|
export interface ExcellentSubmissionQuery {
|
||||||
|
assignmentId: string
|
||||||
|
minPercentage?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 答题扫描图附件结构 —— 由 `getScansAction` 返回。
|
||||||
|
*
|
||||||
|
* 扫描图存储在 `fileAttachments` 表中,`targetType="homework"`、
|
||||||
|
* `targetId=submissionId`。`page` 为按创建时间排序的页码(从 1 开始)。
|
||||||
|
*/
|
||||||
|
export interface ScanAttachment {
|
||||||
|
fileId: string
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
originalName: string
|
||||||
|
/** 页码(按创建时间排序,从 1 开始) */
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user