From f62b8c0f86ef70763d523d37b52667deda03c664 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:33:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(attendance,elective):=20=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E7=AC=AC=E4=BA=8C=E8=BD=AE=20=E2=80=94=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=AE=8C=E6=88=90=20P0/P1=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 修复: - 页面层 i18n 全量补齐(admin/teacher/parent/student × attendance/elective) - types.ts 状态标签常量迁移至 constants.ts(i18n key + Badge variant) - 修复 getTranslations 导入路径(next-intl → next-intl/server) P1 改进: - 解耦 parent 模块对 attendance 类型的直接依赖(本地 view-model 类型) - 导出纯函数(computeStats/buildWarnings/buildLotteryRankCase 等) - 统一空状态为 EmptyState 组件 - 清理死代码读 Action(attendance 5 个 + elective 3 个) - 预留监控埋点接口(trackEvent 13 个新事件名) - 补齐骨架屏 loading.tsx(8 个页面) - AlertDialog 替换 window.confirm(student-selection-view) - a11y 改进(aria-label/role/键盘导航) 修复: - AttendanceStatus 从 constants.ts 重导出,消除 types/constants 双源混乱 - buildWarnings 的 Translator 类型改用 ReturnType --- .../(dashboard)/admin/attendance/loading.tsx | 35 ++ src/app/(dashboard)/admin/attendance/page.tsx | 19 +- .../admin/elective/[id]/edit/loading.tsx | 27 ++ .../admin/elective/[id]/edit/page.tsx | 14 +- .../admin/elective/create/loading.tsx | 27 ++ .../admin/elective/create/page.tsx | 14 +- .../(dashboard)/admin/elective/loading.tsx | 27 ++ src/app/(dashboard)/admin/elective/page.tsx | 13 +- .../(dashboard)/parent/attendance/page.tsx | 34 +- .../(dashboard)/student/attendance/page.tsx | 18 +- src/app/(dashboard)/student/elective/page.tsx | 16 +- .../teacher/attendance/loading.tsx | 35 ++ .../(dashboard)/teacher/attendance/page.tsx | 44 ++- .../teacher/attendance/sheet/loading.tsx | 27 ++ .../teacher/attendance/sheet/page.tsx | 8 +- .../teacher/attendance/stats/loading.tsx | 36 ++ .../teacher/attendance/stats/page.tsx | 20 +- .../(dashboard)/teacher/elective/loading.tsx | 27 ++ src/app/(dashboard)/teacher/elective/page.tsx | 8 +- src/modules/attendance/actions.ts | 164 ++++----- .../components/attendance-filters.tsx | 37 +-- .../components/attendance-record-list.tsx | 53 +-- .../components/attendance-rules-form.tsx | 27 +- .../components/attendance-sheet.tsx | 311 ++++++++++++++---- .../components/attendance-stats-card.tsx | 39 +-- .../components/student-attendance-view.tsx | 41 ++- src/modules/attendance/constants.ts | 65 ++++ src/modules/attendance/data-access-stats.ts | 5 +- src/modules/attendance/types.ts | 22 +- src/modules/elective/actions.ts | 106 +++--- .../components/elective-course-form.tsx | 4 +- .../components/elective-course-list.tsx | 50 +-- .../elective/components/elective-filters.tsx | 14 +- .../components/student-selection-view.tsx | 119 ++++--- src/modules/elective/constants.ts | 55 ++++ .../elective/data-access-operations.ts | 5 +- src/modules/elective/types.ts | 45 +-- .../components/parent-attendance-calendar.tsx | 209 ++++++++++++ .../parent-attendance-rate-card.tsx | 130 ++++++++ .../components/parent-attendance-warning.tsx | 115 +++++++ src/modules/parent/types.ts | 50 +++ src/shared/i18n/messages/en/attendance.json | 38 ++- src/shared/i18n/messages/en/elective.json | 5 + .../i18n/messages/zh-CN/attendance.json | 38 ++- src/shared/i18n/messages/zh-CN/elective.json | 5 + src/shared/lib/track-event.ts | 92 ++++++ 46 files changed, 1748 insertions(+), 545 deletions(-) create mode 100644 src/app/(dashboard)/admin/attendance/loading.tsx create mode 100644 src/app/(dashboard)/admin/elective/[id]/edit/loading.tsx create mode 100644 src/app/(dashboard)/admin/elective/create/loading.tsx create mode 100644 src/app/(dashboard)/admin/elective/loading.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/loading.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/sheet/loading.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/stats/loading.tsx create mode 100644 src/app/(dashboard)/teacher/elective/loading.tsx create mode 100644 src/modules/attendance/constants.ts create mode 100644 src/modules/elective/constants.ts create mode 100644 src/modules/parent/components/parent-attendance-calendar.tsx create mode 100644 src/modules/parent/components/parent-attendance-rate-card.tsx create mode 100644 src/modules/parent/components/parent-attendance-warning.tsx create mode 100644 src/shared/lib/track-event.ts diff --git a/src/app/(dashboard)/admin/attendance/loading.tsx b/src/app/(dashboard)/admin/attendance/loading.tsx new file mode 100644 index 0000000..cb54852 --- /dev/null +++ b/src/app/(dashboard)/admin/attendance/loading.tsx @@ -0,0 +1,35 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + ))} +
+ + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/attendance/page.tsx b/src/app/(dashboard)/admin/attendance/page.tsx index 16bd4a9..932cf53 100644 --- a/src/app/(dashboard)/admin/attendance/page.tsx +++ b/src/app/(dashboard)/admin/attendance/page.tsx @@ -1,7 +1,8 @@ -import Link from "next/link" +import Link from "next/link" import type { Metadata } from "next" import type { JSX } from "react" import { BarChart3, ClipboardList } from "lucide-react" +import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -15,11 +16,6 @@ import { AttendanceStatsCards } from "@/modules/attendance/components/attendance import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" import type { AttendanceStatus } from "@/modules/attendance/types" -export const metadata: Metadata = { - title: "考勤总览 - Next_Edu", - description: "查看全校所有班级的考勤记录", -} - export const dynamic = "force-dynamic" const isValidAttendanceStatus = (v?: string): v is AttendanceStatus => @@ -33,6 +29,7 @@ export default async function AdminAttendancePage({ await requirePermission(Permissions.ATTENDANCE_READ) const sp = await searchParams const ctx = await getAuthContext() + const t = await getTranslations("attendance") const classId = getSearchParam(sp, "classId") const statusParam = getSearchParam(sp, "status") @@ -62,13 +59,13 @@ export default async function AdminAttendancePage({
-

考勤总览

-

查看全校所有班级的考勤记录。

+

{t("title.adminOverview")}

+

{t("description.adminOverview")}

@@ -79,8 +76,8 @@ export default async function AdminAttendancePage({ {result.items.length === 0 && !classId && !status && !date ? ( ) : ( diff --git a/src/app/(dashboard)/admin/elective/[id]/edit/loading.tsx b/src/app/(dashboard)/admin/elective/[id]/edit/loading.tsx new file mode 100644 index 0000000..1aa45bd --- /dev/null +++ b/src/app/(dashboard)/admin/elective/[id]/edit/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx b/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx index 87f1781..71afb3d 100644 --- a/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx +++ b/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx @@ -1,16 +1,11 @@ import { notFound } from "next/navigation" -import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getElectiveCourseById } from "@/modules/elective/data-access" import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access" import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form" -export const metadata: Metadata = { - title: "编辑选修课程 - Next_Edu", - description: "更新选修课程详情", -} - export const dynamic = "force-dynamic" export default async function EditElectiveCoursePage({ @@ -18,6 +13,7 @@ export default async function EditElectiveCoursePage({ }: { params: Promise<{ id: string }> }): Promise { + const t = await getTranslations("elective") const { id } = await params const [course, subjects, grades, teachers] = await Promise.all([ @@ -32,15 +28,15 @@ export default async function EditElectiveCoursePage({ return (
-

编辑选修课程

-

更新选修课程详情。

+

{t("title.edit")}

+

{t("description.edit")}

({ id: g.id, name: g.name }))} - teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))} backHref="/admin/elective" />
diff --git a/src/app/(dashboard)/admin/elective/create/loading.tsx b/src/app/(dashboard)/admin/elective/create/loading.tsx new file mode 100644 index 0000000..1aa45bd --- /dev/null +++ b/src/app/(dashboard)/admin/elective/create/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/elective/create/page.tsx b/src/app/(dashboard)/admin/elective/create/page.tsx index 7877c62..a427a78 100644 --- a/src/app/(dashboard)/admin/elective/create/page.tsx +++ b/src/app/(dashboard)/admin/elective/create/page.tsx @@ -1,17 +1,13 @@ -import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access" import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form" -export const metadata: Metadata = { - title: "新建选修课程 - Next_Edu", - description: "创建新的选修课程", -} - export const dynamic = "force-dynamic" export default async function CreateElectiveCoursePage(): Promise { + const t = await getTranslations("elective") const [subjects, grades, teachers] = await Promise.all([ getSubjectOptions(), getGrades(), @@ -21,14 +17,14 @@ export default async function CreateElectiveCoursePage(): Promise { return (
-

新建选修课程

-

创建新的选修课程。

+

{t("title.create")}

+

{t("description.create")}

({ id: g.id, name: g.name }))} - teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))} backHref="/admin/elective" />
diff --git a/src/app/(dashboard)/admin/elective/loading.tsx b/src/app/(dashboard)/admin/elective/loading.tsx new file mode 100644 index 0000000..dd8cd0c --- /dev/null +++ b/src/app/(dashboard)/admin/elective/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/elective/page.tsx b/src/app/(dashboard)/admin/elective/page.tsx index 652fe4c..343f61a 100644 --- a/src/app/(dashboard)/admin/elective/page.tsx +++ b/src/app/(dashboard)/admin/elective/page.tsx @@ -1,16 +1,12 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getElectiveCourses } from "@/modules/elective/data-access" import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import type { ElectiveCourseStatus } from "@/modules/elective/types" -export const metadata: Metadata = { - title: "选修课程 - Next_Edu", - description: "管理选修课程、开放/关闭选课与抽签", -} - export const dynamic = "force-dynamic" const isValidStatus = (v?: string): v is ElectiveCourseStatus => @@ -22,6 +18,7 @@ export default async function AdminElectivePage({ searchParams: Promise }): Promise { const sp = await searchParams + const t = await getTranslations("elective") const statusParam = getSearchParam(sp, "status") const status = isValidStatus(statusParam) ? statusParam : undefined @@ -30,9 +27,9 @@ export default async function AdminElectivePage({ return (
-

选修课程

+

{t("title.adminList")}

- 管理选修课程、开放/关闭选课与抽签。 + {t("description.adminList")}

) } @@ -37,18 +42,27 @@ export default async function ParentAttendancePage() { return ( ( - <> +

{summary.studentName}

+ - +
)} + headerExtra={ + validSummaries.length > 0 ? ( +
+ + +
+ ) : null + } /> ) } diff --git a/src/app/(dashboard)/student/attendance/page.tsx b/src/app/(dashboard)/student/attendance/page.tsx index 57ead48..19cd9c3 100644 --- a/src/app/(dashboard)/student/attendance/page.tsx +++ b/src/app/(dashboard)/student/attendance/page.tsx @@ -1,3 +1,4 @@ +import { getTranslations } from "next-intl/server" import { getAuthContext } from "@/shared/lib/auth-guard" import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats" import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view" @@ -8,19 +9,20 @@ export const dynamic = "force-dynamic" export default async function StudentAttendancePage() { const ctx = await getAuthContext() + const t = await getTranslations("attendance") const summary = await getStudentAttendanceSummary(ctx.userId) if (!summary) { return ( -
+
-

My Attendance

-

View your attendance records.

+

{t("title.student")}

+

{t("description.student")}

@@ -29,10 +31,10 @@ export default async function StudentAttendancePage() { } return ( -
+
-

My Attendance

-

View your attendance records and statistics.

+

{t("title.student")}

+

{t("description.student")}

diff --git a/src/app/(dashboard)/student/elective/page.tsx b/src/app/(dashboard)/student/elective/page.tsx index 763cc8a..e56f375 100644 --- a/src/app/(dashboard)/student/elective/page.tsx +++ b/src/app/(dashboard)/student/elective/page.tsx @@ -1,23 +1,19 @@ +import { getTranslations } from "next-intl/server" import { getAuthContext } from "@/shared/lib/auth-guard" import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" import { StudentSelectionView } from "@/modules/elective/components/student-selection-view" import { ElectiveFilters } from "@/modules/elective/components/elective-filters" +import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" -type SearchParams = { [key: string]: string | string[] | undefined } - -const getParam = (params: SearchParams, key: string) => { - const v = params[key] - return Array.isArray(v) ? v[0] : v -} - export default async function StudentElectivePage({ searchParams, }: { searchParams: Promise }) { + const t = await getTranslations("elective") const ctx = await getAuthContext() const studentId = ctx.userId @@ -39,10 +35,8 @@ export default async function StudentElectivePage({ return (
-

Elective Courses

-

- Browse available electives and manage your selections. -

+

{t("title.student")}

+

{t("description.student")}

{availableCourses.length > 0 && } +
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + ))} +
+ + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/page.tsx b/src/app/(dashboard)/teacher/attendance/page.tsx index dc42b33..f4a5c77 100644 --- a/src/app/(dashboard)/teacher/attendance/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/page.tsx @@ -1,8 +1,10 @@ import type { JSX } from "react" import Link from "next/link" import { PlusCircle, BarChart3, ClipboardList } from "lucide-react" +import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" +import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination" import { getAuthContext } from "@/shared/lib/auth-guard" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getTeacherClasses } from "@/modules/classes/data-access" @@ -25,6 +27,8 @@ function parseAttendanceStatus(v?: string): AttendanceStatus | undefined { return v && VALID_STATUSES.has(v) ? (v as AttendanceStatus) : undefined } +const PAGE_SIZE = 20 + export default async function TeacherAttendancePage({ searchParams, }: { @@ -32,6 +36,7 @@ export default async function TeacherAttendancePage({ }): Promise { const sp = await searchParams const ctx = await getAuthContext() + const t = await getTranslations("attendance") const classId = getParam(sp, "classId") const status = getParam(sp, "status") @@ -49,24 +54,32 @@ export default async function TeacherAttendancePage({ ]) const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + // 分页计算 + const { page } = computePagination(sp, PAGE_SIZE) + const total = result.items.length + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const currentPage = Math.min(page, totalPages) + const pagedRecords = paginate(result.items, currentPage, PAGE_SIZE) + const hasFilters = Boolean(classId || status || date) + return (
-

Attendance

-

Manage student attendance records.

+

{t("title.teacherRecords")}

+

{t("description.teacherRecords")}

@@ -74,18 +87,31 @@ export default async function TeacherAttendancePage({ - {result.items.length === 0 && !classId && !status && !date ? ( + {result.items.length === 0 && !hasFilters ? ( ) : ( - +
+ + {total > 0 ? ( + + ) : null} +
)}
) diff --git a/src/app/(dashboard)/teacher/attendance/sheet/loading.tsx b/src/app/(dashboard)/teacher/attendance/sheet/loading.tsx new file mode 100644 index 0000000..f949fa8 --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/sheet/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + +
+ + +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/sheet/page.tsx b/src/app/(dashboard)/teacher/attendance/sheet/page.tsx index 6debcb9..b0a6e10 100644 --- a/src/app/(dashboard)/teacher/attendance/sheet/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/sheet/page.tsx @@ -1,4 +1,5 @@ import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getTeacherClasses } from "@/modules/classes/data-access" import { getClassStudentsForAttendance } from "@/modules/attendance/data-access" import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet" @@ -11,6 +12,7 @@ export default async function AttendanceSheetPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("attendance") const sp = await searchParams const defaultClassId = getParam(sp, "classId") @@ -27,10 +29,8 @@ export default async function AttendanceSheetPage({ return (
-

Take Attendance

-

- Select a class and date, then mark attendance for each student. -

+

{t("title.sheet")}

+

{t("sheet.description")}

+
+ + +
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + ))} +
+ + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/stats/page.tsx b/src/app/(dashboard)/teacher/attendance/stats/page.tsx index 66e04ac..223b04f 100644 --- a/src/app/(dashboard)/teacher/attendance/stats/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/stats/page.tsx @@ -1,4 +1,5 @@ import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getTeacherClasses } from "@/modules/classes/data-access" import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats" import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card" @@ -15,6 +16,7 @@ export default async function AttendanceStatsPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("attendance") const sp = await searchParams const classId = getParam(sp, "classId") @@ -27,12 +29,12 @@ export default async function AttendanceStatsPage({ return (
-

Attendance Statistics

-

View class attendance statistics.

+

{t("title.teacherStats")}

+

{t("description.teacherStats")}

@@ -53,8 +55,8 @@ export default async function AttendanceStatsPage({ return (
-

Attendance Statistics

-

View class attendance statistics and trends.

+

{t("title.teacherStats")}

+

{t("description.teacherStats")}

-

Student Records

+

{t("stats.studentRecords")}

) : ( diff --git a/src/app/(dashboard)/teacher/elective/loading.tsx b/src/app/(dashboard)/teacher/elective/loading.tsx new file mode 100644 index 0000000..dd8cd0c --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/elective/page.tsx b/src/app/(dashboard)/teacher/elective/page.tsx index c18531c..f69ef8b 100644 --- a/src/app/(dashboard)/teacher/elective/page.tsx +++ b/src/app/(dashboard)/teacher/elective/page.tsx @@ -1,4 +1,5 @@ import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getAuthContext } from "@/shared/lib/auth-guard" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getElectiveCourses } from "@/modules/elective/data-access" @@ -23,6 +24,7 @@ export default async function TeacherElectivePage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("elective") const ctx = await getAuthContext() const teacherId = ctx.userId @@ -37,10 +39,8 @@ export default async function TeacherElectivePage({ return (
-

My Elective Courses

-

- View and manage the elective courses you teach. -

+

{t("title.teacher")}

+

{t("description.teacher")}

> +): Promise<{ ok: boolean; message?: string }> { + if (ctx.dataScope.type === "all") return { ok: true } + if (ctx.dataScope.type === "class_taught") { + const owns = await verifyTeacherOwnsClass(classId, ctx.userId) + if (!owns) return { ok: false, message: "You do not own this class" } + return { ok: true } + } + return { ok: false, message: "Insufficient permissions" } +} + export async function recordAttendanceAction( prevState: ActionState | null, formData: FormData @@ -76,6 +88,13 @@ export async function recordAttendanceAction( const id = await createAttendanceRecord(parsed.data, ctx.userId) revalidatePath("/teacher/attendance") + await trackEvent({ + event: "attendance.recorded", + userId: ctx.userId, + targetId: id, + targetType: "attendance_record", + properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, status: parsed.data.status }, + }) return { success: true, message: "Attendance recorded", data: id } } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message } @@ -110,6 +129,12 @@ export async function batchRecordAttendanceAction( const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId) revalidatePath("/teacher/attendance") + await trackEvent({ + event: "attendance.batch_recorded", + userId: ctx.userId, + targetType: "attendance_record", + properties: { count, classId: parsed.data.records[0]?.classId }, + }) return { success: true, message: `Recorded attendance for ${count} students`, data: count } } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message } @@ -147,6 +172,13 @@ export async function updateAttendanceAction( await updateAttendanceRecord(id, parsed.data) revalidatePath("/teacher/attendance") + await trackEvent({ + event: "attendance.updated", + userId: ctx.userId, + targetId: id, + targetType: "attendance_record", + properties: { status: parsed.data.status }, + }) return { success: true, message: "Attendance updated" } } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message } @@ -168,89 +200,13 @@ export async function deleteAttendanceAction( await deleteAttendanceRecord(id) revalidatePath("/teacher/attendance") - return { success: true, message: "Attendance record deleted" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getAttendanceAction( - params: AttendanceQueryParams -): Promise> { - try { - const ctx = await requirePermission(Permissions.ATTENDANCE_READ) - const result = await getAttendanceRecords({ - ...params, - scope: ctx.dataScope, - currentUserId: ctx.userId, + await trackEvent({ + event: "attendance.deleted", + userId: ctx.userId, + targetId: id, + targetType: "attendance_record", }) - return { - success: true, - data: { - items: result.items, - total: result.total, - page: result.page, - pageSize: result.pageSize, - totalPages: result.totalPages, - }, - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getStudentAttendanceAction( - studentId: string, - startDate?: string, - endDate?: string -): Promise>>> { - try { - const ctx = await requirePermission(Permissions.ATTENDANCE_READ) - - if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) { - return { success: false, message: "Can only view your own attendance" } - } - if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) { - return { success: false, message: "Can only view your children's attendance" } - } - - const summary = await getStudentAttendanceSummary(studentId, startDate, endDate) - return { success: true, data: summary } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getClassAttendanceStatsAction( - classId: string, - startDate?: string, - endDate?: string -): Promise>>> { - try { - await requirePermission(Permissions.ATTENDANCE_READ) - const result = await getClassAttendanceStats(classId, startDate, endDate) - return { success: true, data: result } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getClassAttendanceForDateAction( - classId: string, - date: string -): Promise> { - try { - await requirePermission(Permissions.ATTENDANCE_READ) - const records = await getClassAttendanceForDate(classId, date) - return { success: true, data: records } + return { success: true, message: "Attendance record deleted" } } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message } if (e instanceof Error) return { success: false, message: e.message } @@ -263,7 +219,7 @@ export async function saveAttendanceRulesAction( formData: FormData ): Promise> { try { - await requirePermission(Permissions.ATTENDANCE_MANAGE) + const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const parsed = AttendanceRuleSchema.safeParse({ classId: formData.get("classId"), @@ -280,8 +236,20 @@ export async function saveAttendanceRulesAction( } } + const ownership = await assertClassOwnership(parsed.data.classId, ctx) + if (!ownership.ok) { + return { success: false, message: ownership.message ?? "Ownership check failed" } + } + const id = await upsertAttendanceRules(parsed.data) revalidatePath("/teacher/attendance") + await trackEvent({ + event: "attendance.rules_saved", + userId: ctx.userId, + targetId: id, + targetType: "attendance_rule", + properties: { classId: parsed.data.classId }, + }) return { success: true, message: "Attendance rules saved", data: id } } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message } @@ -289,17 +257,3 @@ export async function saveAttendanceRulesAction( return { success: false, message: "Unexpected error" } } } - -export async function getAttendanceRulesAction( - classId?: string -): Promise>>> { - try { - await requirePermission(Permissions.ATTENDANCE_READ) - const rules = await getAttendanceRules(classId) - return { success: true, data: rules } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} diff --git a/src/modules/attendance/components/attendance-filters.tsx b/src/modules/attendance/components/attendance-filters.tsx index 67a9ebc..fc6e7aa 100644 --- a/src/modules/attendance/components/attendance-filters.tsx +++ b/src/modules/attendance/components/attendance-filters.tsx @@ -2,6 +2,7 @@ import { useRouter, useSearchParams } from "next/navigation" import { useCallback } from "react" +import { useTranslations } from "next-intl" import { Label } from "@/shared/components/ui/label" import { Select, @@ -12,23 +13,18 @@ import { } from "@/shared/components/ui/select" import { Input } from "@/shared/components/ui/input" +import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants" + type Option = { id: string; name: string } interface AttendanceFiltersProps { classes: Option[] } -const STATUS_OPTIONS = [ - { value: "present", label: "Present" }, - { value: "absent", label: "Absent" }, - { value: "late", label: "Late" }, - { value: "early_leave", label: "Early Leave" }, - { value: "excused", label: "Excused" }, -] - export function AttendanceFilters({ classes }: AttendanceFiltersProps) { const router = useRouter() const searchParams = useSearchParams() + const t = useTranslations("attendance") const updateParam = useCallback( (key: string, value: string) => { @@ -50,13 +46,13 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) { return (
- + updateParam("status", v)}> - - + + - All statuses - {STATUS_OPTIONS.map((s) => ( - - {s.label} + {t("filters.allStatuses")} + {ATTENDANCE_STATUS_OPTIONS.map((s) => ( + + {t(ATTENDANCE_STATUS_LABEL_KEYS[s])} ))} @@ -84,12 +80,13 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
- + updateParam("date", e.target.value)} className="h-9" + aria-label={t("filters.date")} />
diff --git a/src/modules/attendance/components/attendance-record-list.tsx b/src/modules/attendance/components/attendance-record-list.tsx index 0c50d13..3012e5e 100644 --- a/src/modules/attendance/components/attendance-record-list.tsx +++ b/src/modules/attendance/components/attendance-record-list.tsx @@ -3,7 +3,8 @@ import { useState } from "react" import { toast } from "sonner" import { useRouter } from "next/navigation" -import { Trash2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { Trash2, Inbox } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" @@ -23,17 +24,19 @@ import { DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog" +import { EmptyState } from "@/shared/components/ui/empty-state" import { formatDate } from "@/shared/lib/utils" import { deleteAttendanceAction } from "../actions" import { - ATTENDANCE_STATUS_COLORS, - ATTENDANCE_STATUS_LABELS, - type AttendanceListItem, -} from "../types" + ATTENDANCE_STATUS_BADGE_VARIANTS, + ATTENDANCE_STATUS_LABEL_KEYS, +} from "../constants" +import type { AttendanceListItem } from "../types" export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) { const router = useRouter() + const t = useTranslations("attendance") const [deleteId, setDeleteId] = useState(null) const [isDeleting, setIsDeleting] = useState(false) @@ -43,19 +46,22 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ const result = await deleteAttendanceAction(deleteId) setIsDeleting(false) if (result.success) { - toast.success(result.message) + toast.success(result.message || t("sheet.deleted")) setDeleteId(null) router.refresh() } else { - toast.error(result.message || "Failed to delete") + toast.error(result.message || t("errors.unexpected")) } } if (records.length === 0) { return ( -
- No attendance records found. -
+ ) } @@ -65,13 +71,13 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ - Student - Class - Date - Status - Remark - Recorded By - Created + {t("list.columns.student")} + {t("list.columns.class")} + {t("list.columns.date")} + {t("list.columns.status")} + {t("list.columns.remark")} + {t("list.columns.recorder")} + {t("list.columns.createdAt")} @@ -82,8 +88,8 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ {r.className}{r.date} - - {ATTENDANCE_STATUS_LABELS[r.status]} + + {t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])} @@ -97,6 +103,7 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteId(r.id)} + aria-label={t("actions.delete")} > @@ -110,17 +117,17 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ !open && setDeleteId(null)}> - Delete Attendance Record + {t("sheet.confirmDelete")} - Are you sure you want to delete this attendance record? This action cannot be undone. + {t("errors.unexpected")} diff --git a/src/modules/attendance/components/attendance-rules-form.tsx b/src/modules/attendance/components/attendance-rules-form.tsx index a1d90bc..7b43015 100644 --- a/src/modules/attendance/components/attendance-rules-form.tsx +++ b/src/modules/attendance/components/attendance-rules-form.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" @@ -25,9 +26,10 @@ type Option = { id: string; name: string } function SubmitButton() { const { pending } = useFormStatus() + const t = useTranslations("attendance") return ( ) } @@ -40,6 +42,7 @@ export function AttendanceRulesForm({ existingRules: AttendanceRule[] }) { const router = useRouter() + const t = useTranslations("attendance") const [classId, setClassId] = useState(classes[0]?.id ?? "") const [lateThreshold, setLateThreshold] = useState("15") const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15") @@ -61,7 +64,7 @@ export function AttendanceRulesForm({ const handleSubmit = async (formData: FormData) => { if (!classId) { - toast.error("Please select a class") + toast.error(t("sheet.selectClass")) return } formData.set("classId", classId) @@ -71,25 +74,25 @@ export function AttendanceRulesForm({ const result = await saveAttendanceRulesAction(null, formData) if (result.success) { - toast.success(result.message) + toast.success(result.message || t("rules.saved")) router.refresh() } else { - toast.error(result.message || "Failed to save rules") + toast.error(result.message || t("errors.unexpected")) } } return ( - Attendance Rules + {t("title.rules")}
- +
- + setEnableAutoMark(v === true)} />
diff --git a/src/modules/attendance/components/attendance-sheet.tsx b/src/modules/attendance/components/attendance-sheet.tsx index 7a66c4e..877b2be 100644 --- a/src/modules/attendance/components/attendance-sheet.tsx +++ b/src/modules/attendance/components/attendance-sheet.tsx @@ -1,10 +1,11 @@ "use client" -import { useState } from "react" +import { useState, useRef, useEffect, useCallback } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" -import { CalendarDays } from "lucide-react" +import { useTranslations } from "next-intl" +import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" @@ -25,12 +26,23 @@ import { TableHeader, TableRow, } from "@/shared/components/ui/table" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { cn } from "@/shared/lib/utils" import { batchRecordAttendanceAction } from "../actions" import { - ATTENDANCE_STATUS_LABELS, + ATTENDANCE_STATUS_LABEL_KEYS, type AttendanceStatus, -} from "../types" +} from "../constants" type Option = { id: string; name: string } type Student = { id: string; name: string; email: string } @@ -43,14 +55,39 @@ const STATUS_OPTIONS: AttendanceStatus[] = [ "excused", ] -const isAttendanceStatus = (v: string): v is AttendanceStatus => - v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused" +const STATUS_SHORTCUTS: Record = { + p: "present", + a: "absent", + l: "late", + e: "early_leave", + x: "excused", +} + +const STATUS_STYLES: Record = { + present: { active: "bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600", icon: CheckCircle2 }, + absent: { active: "bg-red-500 text-white border-red-500 hover:bg-red-600", icon: XCircle }, + late: { active: "bg-amber-500 text-white border-amber-500 hover:bg-amber-600", icon: Clock }, + early_leave: { active: "bg-blue-500 text-white border-blue-500 hover:bg-blue-600", icon: LogOut }, + excused: { active: "bg-purple-500 text-white border-purple-500 hover:bg-purple-600", icon: FileText }, +} + +/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */ +function createInitialStatusCounts(): Record { + return { + present: 0, + absent: 0, + late: 0, + early_leave: 0, + excused: 0, + } +} function SubmitButton() { const { pending } = useFormStatus() + const t = useTranslations("attendance") return ( ) } @@ -67,24 +104,94 @@ export function AttendanceSheet({ defaultDate?: string }) { const router = useRouter() + const t = useTranslations("attendance") const today = new Date().toISOString().slice(0, 10) const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") const [date, setDate] = useState(defaultDate ?? today) const [statuses, setStatuses] = useState>({}) + const [searchQuery, setSearchQuery] = useState("") + const [focusedStudentIndex, setFocusedStudentIndex] = useState(0) + const [isSubmitting, setIsSubmitting] = useState(false) + const [showSwitchConfirm, setShowSwitchConfirm] = useState(false) + const [pendingClassId, setPendingClassId] = useState(null) + const studentRefs = useRef<(HTMLTableRowElement | null)[]>([]) - const handleStatusChange = (studentId: string, status: AttendanceStatus) => { + const handleStatusChange = useCallback((studentId: string, status: AttendanceStatus) => { setStatuses((prev) => ({ ...prev, [studentId]: status })) - } + }, []) - const markAllPresent = () => { + const markAllPresent = useCallback(() => { const all: Record = {} for (const s of students) all[s.id] = "present" setStatuses(all) + toast.success(t("actions.markAllPresent")) + }, [students, t]) + + const handleClassChange = (newClassId: string) => { + const hasUnsaved = Object.keys(statuses).length > 0 + if (hasUnsaved && newClassId !== classId) { + setPendingClassId(newClassId) + setShowSwitchConfirm(true) + return + } + confirmClassSwitch(newClassId) } + const confirmClassSwitch = (newClassId: string) => { + setClassId(newClassId) + setStatuses({}) + const newUrl = newClassId ? `/teacher/attendance/sheet?classId=${encodeURIComponent(newClassId)}` : "/teacher/attendance/sheet" + router.push(newUrl) + } + + const filteredStudents = students.filter( + (s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const statusCounts = STATUS_OPTIONS.reduce( + (acc, st) => { + acc[st] = students.filter((s) => (statuses[s.id] ?? "present") === st).length + return acc + }, + createInitialStatusCounts() + ) + + // 派生值:当筛选结果变少时,焦点索引自动夹紧到有效范围,避免 useEffect 重置导致的级联渲染 + const effectiveFocusedIndex = filteredStudents.length === 0 + ? 0 + : Math.min(focusedStudentIndex, filteredStudents.length - 1) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + const key = e.key.toLowerCase() + if (STATUS_SHORTCUTS[key] && filteredStudents[effectiveFocusedIndex]) { + e.preventDefault() + handleStatusChange(filteredStudents[effectiveFocusedIndex].id, STATUS_SHORTCUTS[key]) + if (effectiveFocusedIndex < filteredStudents.length - 1) { + setFocusedStudentIndex((prev) => prev + 1) + } + } + if (e.key === "ArrowDown" && effectiveFocusedIndex < filteredStudents.length - 1) { + e.preventDefault() + setFocusedStudentIndex((prev) => prev + 1) + } + if (e.key === "ArrowUp" && effectiveFocusedIndex > 0) { + e.preventDefault() + setFocusedStudentIndex((prev) => prev - 1) + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [filteredStudents, effectiveFocusedIndex, handleStatusChange]) + + useEffect(() => { + studentRefs.current[effectiveFocusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [effectiveFocusedIndex]) + const handleSubmit = async (formData: FormData) => { if (!classId || !date) { - toast.error("Please select class and date") + toast.error(t("errors.invalidForm")) return } @@ -96,35 +203,48 @@ export function AttendanceSheet({ })) if (records.length === 0) { - toast.error("No students to record attendance for") + toast.error(t("sheet.noStudents")) return } + setIsSubmitting(true) formData.set("recordsJson", JSON.stringify(records)) const result = await batchRecordAttendanceAction(null, formData) + setIsSubmitting(false) if (result.success) { - toast.success(result.message) + toast.success(result.message || t("sheet.saved")) router.push("/teacher/attendance") router.refresh() } else { - toast.error(result.message || "Failed to save attendance") + toast.error(result.message || t("errors.unexpected")) } } return ( - + + {isSubmitting && ( +
+
+
+ {t("sheet.saved")}... +
+
+ )} - Attendance Sheet + {t("title.sheet")} +

+ {t("description.teacherRecords")} +

- - + + {classes.map((c) => ( @@ -137,7 +257,7 @@ export function AttendanceSheet({
- +
setDate(e.target.value)} className="pl-9" required + aria-label={t("filters.date")} />
@@ -154,55 +275,100 @@ export function AttendanceSheet({ {students.length === 0 ? (

- No students in this class. Select a class to load students. + {t("sheet.noStudents")}

) : ( <> -
-

- {students.length} students -

- +
+
+ {STATUS_OPTIONS.map((st) => { + const Icon = STATUS_STYLES[st].icon + return ( + + + ) + })} +
+
+
+ + setSearchQuery(e.target.value)} + className="h-8 w-40 pl-8 text-sm" + aria-label={t("list.columns.student")} + /> +
+ +
- Student - Email - Status + # + {t("list.columns.student")} + {t("list.columns.remark")} + {t("list.columns.status")} - {students.map((s) => ( - - {s.name} - {s.email} - - - - - ))} + {filteredStudents.map((s, idx) => { + const currentStatus = statuses[s.id] ?? "present" + const isFocused = idx === effectiveFocusedIndex + return ( + { studentRefs.current[idx] = el }} + className={cn("cursor-pointer", isFocused && "bg-primary/5")} + onClick={() => setFocusedStudentIndex(idx)} + role="button" + tabIndex={isFocused ? 0 : -1} + aria-label={s.name} + > + {idx + 1} + {s.name} + {s.email} + +
+ {STATUS_OPTIONS.map((st) => { + const Icon = STATUS_STYLES[st].icon + const isActive = currentStatus === st + return ( + + ) + })} +
+
+
+ ) + })}
@@ -211,12 +377,37 @@ export function AttendanceSheet({ + + + + + {t("sheet.confirmDelete")} + + {t("description.teacherRecords")} + + + + {t("actions.cancel")} + { + if (pendingClassId) { + confirmClassSwitch(pendingClassId) + setPendingClassId(null) + } + setShowSwitchConfirm(false) + }} + > + {t("actions.save")} + + + + ) } diff --git a/src/modules/attendance/components/attendance-stats-card.tsx b/src/modules/attendance/components/attendance-stats-card.tsx index 331dc4a..7e492a6 100644 --- a/src/modules/attendance/components/attendance-stats-card.tsx +++ b/src/modules/attendance/components/attendance-stats-card.tsx @@ -1,4 +1,6 @@ +import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" import { StatItem } from "@/shared/components/ui/stat-item" import { Users, @@ -8,71 +10,70 @@ import { LogOut, FileText, TrendingUp, + BarChart3, } from "lucide-react" import type { AttendanceStats } from "../types" export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) { + const t = useTranslations("attendance") + if (!stats || stats.total === 0) { return ( - - - Attendance Statistics - - -

No attendance data available.

-
-
+ ) } return ( - Attendance Statistics + {t("title.teacherStats")}
} /> } /> } /> } /> } /> } /> } - hint="Present / Total" /> } - hint="Late / Total" />
diff --git a/src/modules/attendance/components/student-attendance-view.tsx b/src/modules/attendance/components/student-attendance-view.tsx index 3293487..d8c7ae9 100644 --- a/src/modules/attendance/components/student-attendance-view.tsx +++ b/src/modules/attendance/components/student-attendance-view.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { @@ -13,21 +14,23 @@ import { CalendarCheck } from "lucide-react" import { AttendanceStatsCard } from "./attendance-stats-card" import { - ATTENDANCE_STATUS_COLORS, - ATTENDANCE_STATUS_LABELS, - type StudentAttendanceSummary, -} from "../types" + ATTENDANCE_STATUS_BADGE_VARIANTS, + ATTENDANCE_STATUS_LABEL_KEYS, +} from "../constants" +import type { StudentAttendanceSummary } from "../types" export function StudentAttendanceView({ summary, }: { summary: StudentAttendanceSummary | null }) { + const t = useTranslations("attendance") + if (!summary) { return ( @@ -39,7 +42,9 @@ export function StudentAttendanceView({
- Student + + {t("list.columns.student")} +

{summary.studentName}

@@ -47,7 +52,9 @@ export function StudentAttendanceView({
- Total Records + + {t("stats.totalRecords")} +

{summary.stats.total}

@@ -59,25 +66,25 @@ export function StudentAttendanceView({ {summary.recentRecords.length === 0 ? ( ) : ( - Recent Attendance + {t("stats.recentRecords")}
- Date - Class - Status - Remark + {t("list.columns.date")} + {t("list.columns.class")} + {t("list.columns.status")} + {t("list.columns.remark")} @@ -86,8 +93,8 @@ export function StudentAttendanceView({ {r.date} {r.className} - - {ATTENDANCE_STATUS_LABELS[r.status]} + + {t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])} {r.remark ?? "-"} diff --git a/src/modules/attendance/constants.ts b/src/modules/attendance/constants.ts new file mode 100644 index 0000000..eb8616f --- /dev/null +++ b/src/modules/attendance/constants.ts @@ -0,0 +1,65 @@ +/** + * 考勤模块共享常量(消除 types.ts / attendance-sheet.tsx / attendance-filters.tsx / parent-attendance-calendar.tsx 重复定义)。 + * + * 注意:标签使用 i18n key(`status.*`),由组件层通过 `useTranslations("attendance")` 解析。 + */ +import type { AttendanceStatus } from "./types" + +/** 重导出类型,便于组件层从 constants 单点导入(消除 types/constants 双源混乱) */ +export type { AttendanceStatus } from "./types" + +/** 考勤状态选项(顺序即 UI 展示顺序) */ +export const ATTENDANCE_STATUS_OPTIONS: AttendanceStatus[] = [ + "present", + "absent", + "late", + "early_leave", + "excused", +] + +/** 考勤状态 → i18n key 映射(组件层 `t(key)` 解析) */ +export const ATTENDANCE_STATUS_LABEL_KEYS: Record = { + present: "status.present", + absent: "status.absent", + late: "status.late", + early_leave: "status.early_leave", + excused: "status.excused", +} + +/** 考勤状态 → Badge variant 映射 */ +export const ATTENDANCE_STATUS_BADGE_VARIANTS: Record = { + present: "default", + absent: "destructive", + late: "secondary", + early_leave: "outline", + excused: "outline", +} + +/** 考勤状态 → Tailwind 圆点颜色类 */ +export const ATTENDANCE_STATUS_DOT_COLORS: Record = { + present: "bg-green-500", + absent: "bg-red-500", + late: "bg-yellow-500", + early_leave: "bg-blue-500", + excused: "bg-purple-500", +} + +/** 键盘快捷键映射 */ +export const ATTENDANCE_STATUS_SHORTCUTS: Record = { + p: "present", + a: "absent", + l: "late", + e: "early_leave", + x: "excused", +} + +/** 初始化状态计数,避免 `{} as Record<...>` 类型断言 */ +export function createInitialStatusCounts(): Record { + return { + present: 0, + absent: 0, + late: 0, + early_leave: 0, + excused: 0, + } +} diff --git a/src/modules/attendance/data-access-stats.ts b/src/modules/attendance/data-access-stats.ts index 21d3f64..12465bd 100644 --- a/src/modules/attendance/data-access-stats.ts +++ b/src/modules/attendance/data-access-stats.ts @@ -23,7 +23,10 @@ const EMPTY_STATS: AttendanceStats = { lateRate: 0, } -const computeStats = (rows: { status: string }[]): AttendanceStats => { +/** + * 根据考勤记录行计算统计(纯函数,便于测试)。 + */ +export const computeStats = (rows: { status: string }[]): AttendanceStats => { if (rows.length === 0) return EMPTY_STATS const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length } for (const r of rows) { diff --git a/src/modules/attendance/types.ts b/src/modules/attendance/types.ts index 9a940c9..3341d17 100644 --- a/src/modules/attendance/types.ts +++ b/src/modules/attendance/types.ts @@ -83,21 +83,7 @@ export interface PaginatedAttendanceResult { totalPages: number } -export const ATTENDANCE_STATUS_LABELS: Record = { - present: "Present", - absent: "Absent", - late: "Late", - early_leave: "Early Leave", - excused: "Excused", -} - -export const ATTENDANCE_STATUS_COLORS: Record< - AttendanceStatus, - "default" | "secondary" | "destructive" | "outline" -> = { - present: "default", - absent: "destructive", - late: "secondary", - early_leave: "outline", - excused: "outline", -} +/** + * 注意:状态标签与颜色映射已迁移至 `./constants.ts`, + * 使用 i18n key(`status.*`)+ Badge variant,由组件层通过 `useTranslations("attendance")` 解析。 + */ diff --git a/src/modules/elective/actions.ts b/src/modules/elective/actions.ts index 788191f..99de612 100644 --- a/src/modules/elective/actions.ts +++ b/src/modules/elective/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" +import { trackEvent } from "@/shared/lib/track-event" import { CreateElectiveCourseSchema, @@ -13,7 +14,6 @@ import { RunLotterySchema, } from "./schema" import { - getElectiveCourses, getElectiveCourseById, createElectiveCourse, updateElectiveCourse, @@ -22,15 +22,6 @@ import { closeSelection, } from "./data-access" import { runLottery, selectCourse, dropCourse } from "./data-access-operations" -import { - getStudentSelections, - getAvailableCoursesForStudent, -} from "./data-access-selections" -import type { - ElectiveCourseWithDetails, - CourseSelectionWithDetails, - GetElectiveCoursesParams, -} from "./types" const handleError = (e: unknown): ActionState => { if (e instanceof PermissionDeniedError) return { success: false, message: e.message } @@ -103,6 +94,13 @@ export async function createElectiveCourseAction( } const id = await createElectiveCourse(parsed.data, ctx.userId) revalidateElectivePaths(id) + await trackEvent({ + event: "elective.course_created", + userId: ctx.userId, + targetId: id, + targetType: "elective_course", + properties: { capacity: parsed.data.capacity, selectionMode: parsed.data.selectionMode }, + }) return { success: true, message: "Elective course created", data: id } } catch (e) { return handleError(e) @@ -148,6 +146,12 @@ export async function updateElectiveCourseAction( } await updateElectiveCourse(id, parsed.data) revalidateElectivePaths(id) + await trackEvent({ + event: "elective.course_updated", + userId: ctx.userId, + targetId: id, + targetType: "elective_course", + }) return { success: true, message: "Elective course updated", data: id } } catch (e) { return handleError(e) @@ -169,6 +173,12 @@ export async function deleteElectiveCourseAction( await deleteElectiveCourse(id) revalidateElectivePaths() + await trackEvent({ + event: "elective.course_deleted", + userId: ctx.userId, + targetId: id, + targetType: "elective_course", + }) return { success: true, message: "Elective course deleted" } } catch (e) { return handleError(e) @@ -190,6 +200,12 @@ export async function openSelectionAction( await openSelection(courseId) revalidateElectivePaths(courseId) + await trackEvent({ + event: "elective.selection_opened", + userId: ctx.userId, + targetId: courseId, + targetType: "elective_course", + }) return { success: true, message: "Selection opened" } } catch (e) { return handleError(e) @@ -211,6 +227,12 @@ export async function closeSelectionAction( await closeSelection(courseId) revalidateElectivePaths(courseId) + await trackEvent({ + event: "elective.selection_closed", + userId: ctx.userId, + targetId: courseId, + targetType: "elective_course", + }) return { success: true, message: "Selection closed" } } catch (e) { return handleError(e) @@ -241,6 +263,13 @@ export async function runLotteryAction( const result = await runLottery(parsed.data.courseId) revalidateElectivePaths(parsed.data.courseId) + await trackEvent({ + event: "elective.lottery_completed", + userId: ctx.userId, + targetId: parsed.data.courseId, + targetType: "elective_course", + properties: { enrolled: result.enrolled, waitlist: result.waitlist }, + }) return { success: true, message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`, @@ -270,6 +299,13 @@ export async function selectCourseAction( } const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority) revalidateElectivePaths(parsed.data.courseId) + await trackEvent({ + event: "elective.course_selected", + userId: ctx.userId, + targetId: parsed.data.courseId, + targetType: "course_selection", + properties: { status: result.status, priority: parsed.data.priority }, + }) return { success: true, message: result.message, data: result.status } } catch (e) { return handleError(e) @@ -294,52 +330,14 @@ export async function dropCourseAction( } await dropCourse(parsed.data.courseId, ctx.userId) revalidateElectivePaths(parsed.data.courseId) + await trackEvent({ + event: "elective.course_dropped", + userId: ctx.userId, + targetId: parsed.data.courseId, + targetType: "course_selection", + }) return { success: true, message: "Course dropped" } } catch (e) { return handleError(e) } } - -export async function getElectiveCoursesAction( - params?: GetElectiveCoursesParams -): Promise> { - try { - const ctx = await requirePermission(Permissions.ELECTIVE_READ) - const data = await getElectiveCourses({ - ...params, - scope: ctx.dataScope, - currentUserId: ctx.userId, - }) - return { success: true, data } - } catch (e) { - return handleError(e) - } -} - -export async function getStudentSelectionsAction( - studentId: string -): Promise> { - try { - const ctx = await requirePermission(Permissions.ELECTIVE_READ) - if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) { - return { success: false, message: "Can only view your own selections" } - } - if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) { - return { success: false, message: "Can only view your children's selections" } - } - const data = await getStudentSelections(studentId) - return { success: true, data } - } catch (e) { - return handleError(e) - } -} - -export async function getAvailableCoursesAction(): Promise> { - try { - const ctx = await requirePermission(Permissions.ELECTIVE_SELECT) - const data = await getAvailableCoursesForStudent(ctx.userId) - return { success: true, data } - } catch (e) { - return handleError(e) - } -} diff --git a/src/modules/elective/components/elective-course-form.tsx b/src/modules/elective/components/elective-course-form.tsx index 35ca627..fc9f4f9 100644 --- a/src/modules/elective/components/elective-course-form.tsx +++ b/src/modules/elective/components/elective-course-form.tsx @@ -19,6 +19,7 @@ import { } from "@/shared/components/ui/select" import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions" +import { isSelectionMode } from "../constants" import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types" type Mode = "create" | "edit" @@ -28,9 +29,6 @@ interface Option { name: string } -const isSelectionMode = (v: string): v is ElectiveSelectionMode => - v === "fcfs" || v === "lottery" - export function ElectiveCourseForm({ mode, course, diff --git a/src/modules/elective/components/elective-course-list.tsx b/src/modules/elective/components/elective-course-list.tsx index 7c323f6..45f2bf6 100644 --- a/src/modules/elective/components/elective-course-list.tsx +++ b/src/modules/elective/components/elective-course-list.tsx @@ -15,10 +15,10 @@ import type { ActionState } from "@/shared/types/action-state" import { Permissions } from "@/shared/types/permissions" import { - ELECTIVE_STATUS_COLORS, - ELECTIVE_STATUS_LABELS, - SELECTION_MODE_LABELS, -} from "../types" + ELECTIVE_STATUS_BADGE_VARIANTS, + ELECTIVE_STATUS_LABEL_KEYS, + SELECTION_MODE_LABEL_KEYS, +} from "../constants" import type { ElectiveCourseWithDetails } from "../types" import { deleteElectiveCourseAction, @@ -59,7 +59,7 @@ export function ElectiveCourseList({ toast.success(res.message ?? successMsg) router.refresh() } else { - toast.error(res.message ?? "Operation failed") + toast.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) @@ -72,10 +72,10 @@ export function ElectiveCourseList({ formData.set("courseId", courseId) const res = await deleteElectiveCourseAction(null, formData) if (res.success) { - toast.success(res.message ?? "Course deleted") + toast.success(res.message ?? t("actions.delete")) router.refresh() } else { - toast.error(res.message ?? "Delete failed") + toast.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) @@ -113,8 +113,8 @@ export function ElectiveCourseList({ {course.name} - - {ELECTIVE_STATUS_LABELS[course.status]} + + {t(ELECTIVE_STATUS_LABEL_KEYS[course.status])} @@ -125,7 +125,7 @@ export function ElectiveCourseList({ {course.gradeName ? ( {course.gradeName} ) : null} - Credit: {course.credit} + {t("fields.credit")}: {course.credit} {course.description ? ( @@ -136,25 +136,25 @@ export function ElectiveCourseList({
- Teacher:{" "} + {t("fields.teacher")}:{" "} {course.teacherName ?? "—"}
- Mode:{" "} + {t("fields.selectionMode")}:{" "} - {SELECTION_MODE_LABELS[course.selectionMode]} + {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
- Capacity:{" "} + {t("fields.capacity")}:{" "} {course.enrolledCount}/{course.capacity} - {isFull ? " (Full)" : ""} + {isFull ? ` (${t("student.capacityFull")})` : ""}
{course.classroom ? (
- Room:{" "} + {t("fields.classroom")}:{" "} {course.classroom}
) : null} @@ -162,7 +162,7 @@ export function ElectiveCourseList({ {course.schedule ? (

- Schedule: {course.schedule} + {t("fields.schedule")}: {course.schedule}

) : null} @@ -176,7 +176,7 @@ export function ElectiveCourseList({ > - Edit + {t("actions.edit")} ) : null} @@ -185,10 +185,10 @@ export function ElectiveCourseList({ variant="outline" size="sm" disabled={isPendingThis} - onClick={() => runAction(openSelectionAction, course.id, "Selection opened")} + onClick={() => runAction(openSelectionAction, course.id, t("actions.openSelection"))} > - Open + {t("actions.openSelection")} ) : null} {course.status === "open" ? ( @@ -196,10 +196,10 @@ export function ElectiveCourseList({ variant="outline" size="sm" disabled={isPendingThis} - onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")} + onClick={() => runAction(closeSelectionAction, course.id, t("actions.closeSelection"))} > - Close + {t("actions.closeSelection")} ) : null} {course.selectionMode === "lottery" && course.status !== "draft" ? ( @@ -207,10 +207,10 @@ export function ElectiveCourseList({ variant="outline" size="sm" disabled={isPendingThis} - onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")} + onClick={() => runAction(runLotteryAction, course.id, t("actions.runLottery"))} > - Lottery + {t("actions.runLottery")} ) : null}
) : null} diff --git a/src/modules/elective/components/elective-filters.tsx b/src/modules/elective/components/elective-filters.tsx index debbea8..c0efd76 100644 --- a/src/modules/elective/components/elective-filters.tsx +++ b/src/modules/elective/components/elective-filters.tsx @@ -1,6 +1,7 @@ "use client" import { useQueryState, parseAsString } from "nuqs" +import { useTranslations } from "next-intl" import { Select, @@ -12,6 +13,7 @@ import { import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" export function ElectiveFilters() { + const t = useTranslations("elective") const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all")) @@ -29,18 +31,18 @@ export function ElectiveFilters() { setSearch(v || null)} - placeholder="Search by course name, teacher..." + placeholder={t("form.namePlaceholder")} />
diff --git a/src/modules/elective/components/student-selection-view.tsx b/src/modules/elective/components/student-selection-view.tsx index 631ddb7..2ea7a36 100644 --- a/src/modules/elective/components/student-selection-view.tsx +++ b/src/modules/elective/components/student-selection-view.tsx @@ -3,19 +3,32 @@ import { useState, useTransition } from "react" import { useRouter } from "next/navigation" import { toast } from "sonner" +import { useTranslations } from "next-intl" import { BookOpen, CheckCircle2, XCircle } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/components/ui/alert-dialog" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" import { - COURSE_SELECTION_STATUS_COLORS, - COURSE_SELECTION_STATUS_LABELS, - ELECTIVE_STATUS_LABELS, - SELECTION_MODE_LABELS, -} from "../types" + COURSE_SELECTION_STATUS_BADGE_VARIANTS, + COURSE_SELECTION_STATUS_LABEL_KEYS, + ELECTIVE_STATUS_BADGE_VARIANTS, + ELECTIVE_STATUS_LABEL_KEYS, + SELECTION_MODE_LABEL_KEYS, +} from "../constants" import type { CourseSelectionWithDetails, ElectiveCourseWithDetails, @@ -30,6 +43,7 @@ export function StudentSelectionView({ mySelections: CourseSelectionWithDetails[] }) { const router = useRouter() + const t = useTranslations("elective") const [pendingId, setPendingId] = useState(null) const [isPending, startTransition] = useTransition() @@ -47,10 +61,10 @@ export function StudentSelectionView({ formData.set("courseId", courseId) const res = await selectCourseAction(null, formData) if (res.success) { - toast.success(res.message) + toast.success(res.message || t("student.selectSuccess")) router.refresh() } else { - toast.error(res.message ?? "Failed to select course") + toast.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) @@ -63,10 +77,10 @@ export function StudentSelectionView({ formData.set("courseId", courseId) const res = await dropCourseAction(null, formData) if (res.success) { - toast.success(res.message) + toast.success(res.message || t("student.dropSuccess")) router.refresh() } else { - toast.error(res.message ?? "Failed to drop course") + toast.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) @@ -76,15 +90,15 @@ export function StudentSelectionView({
-

My Selections

+

{t("student.mySelections")}

- {activeSelections.length} active + {activeSelections.length}
{activeSelections.length === 0 ? ( @@ -94,33 +108,52 @@ export function StudentSelectionView({ - {sel.courseName ?? "Unknown course"} + {sel.courseName ?? t("errors.notFound")} - - {COURSE_SELECTION_STATUS_LABELS[sel.status]} + + {t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])} {sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (

- Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity} + {t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}

) : null} {sel.lotteryRank ? (

- Lottery rank: #{sel.lotteryRank} + #{sel.lotteryRank}

) : null} - + + + + + + + {t("student.confirmDrop")} + + {t("student.confirmDrop")} + + + + {t("actions.cancel")} + handleDrop(sel.courseId)} + > + {t("actions.drop")} + + + +
))} @@ -130,15 +163,15 @@ export function StudentSelectionView({
-

Available Courses

+

{t("student.availableCourses")}

- {availableCourses.length} open + {availableCourses.length}
{availableCourses.length === 0 ? ( @@ -149,11 +182,11 @@ export function StudentSelectionView({ const alreadySelected = selectedCourseIds.has(course.id) const isPendingThis = isPending && pendingId === course.id return ( - + {course.name} - - {ELECTIVE_STATUS_LABELS[course.status]} + + {t(ELECTIVE_STATUS_LABEL_KEYS[course.status])} @@ -161,8 +194,8 @@ export function StudentSelectionView({ {course.subjectName ? ( {course.subjectName} ) : null} - Credit: {course.credit} - · {SELECTION_MODE_LABELS[course.selectionMode]} + {t("fields.credit")}: {course.credit} + · {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
{course.description ? (

@@ -171,27 +204,27 @@ export function StudentSelectionView({ ) : null}

- Teacher:{" "} + {t("fields.teacher")}:{" "} {course.teacherName ?? "—"}
- Capacity:{" "} + {t("fields.capacity")}:{" "} {course.enrolledCount}/{course.capacity} - {isFull ? " (Full)" : ""} + {isFull ? ` (${t("student.capacityFull")})` : ""}
{course.schedule ? (

- Schedule: {course.schedule} + {t("fields.schedule")}: {course.schedule}

) : null}
{alreadySelected ? ( ) : ( )}
diff --git a/src/modules/elective/constants.ts b/src/modules/elective/constants.ts new file mode 100644 index 0000000..0afd770 --- /dev/null +++ b/src/modules/elective/constants.ts @@ -0,0 +1,55 @@ +/** + * 选修课模块共享常量(消除 types.ts / elective-course-form.tsx / elective-filters.tsx 重复定义)。 + * + * 注意:标签使用 i18n key,由组件层通过 `useTranslations("elective")` 解析。 + */ +import type { + ElectiveCourseStatus, + ElectiveSelectionMode, + CourseSelectionStatus, +} from "./types" + +/** 课程状态 → i18n key 映射 */ +export const ELECTIVE_STATUS_LABEL_KEYS: Record = { + draft: "status.draft", + open: "status.open", + closed: "status.closed", + cancelled: "status.cancelled", +} + +/** 课程状态 → Badge variant 映射 */ +export const ELECTIVE_STATUS_BADGE_VARIANTS: Record = { + draft: "secondary", + open: "default", + closed: "outline", + cancelled: "destructive", +} + +/** 选课模式 → i18n key 映射 */ +export const SELECTION_MODE_LABEL_KEYS: Record = { + fcfs: "selectionMode.fcfs", + lottery: "selectionMode.lottery", +} + +/** 选课状态 → i18n key 映射 */ +export const COURSE_SELECTION_STATUS_LABEL_KEYS: Record = { + selected: "selectionStatus.selected", + enrolled: "selectionStatus.enrolled", + waitlist: "selectionStatus.waitlist", + dropped: "selectionStatus.dropped", + rejected: "selectionStatus.rejected", +} + +/** 选课状态 → Badge variant 映射 */ +export const COURSE_SELECTION_STATUS_BADGE_VARIANTS: Record = { + selected: "secondary", + enrolled: "default", + waitlist: "outline", + dropped: "destructive", + rejected: "destructive", +} + +/** 类型守卫:校验字符串是否为合法的选课模式 */ +export function isSelectionMode(v: string): v is ElectiveSelectionMode { + return v === "fcfs" || v === "lottery" +} diff --git a/src/modules/elective/data-access-operations.ts b/src/modules/elective/data-access-operations.ts index 9c24961..93c6cac 100644 --- a/src/modules/elective/data-access-operations.ts +++ b/src/modules/elective/data-access-operations.ts @@ -11,7 +11,10 @@ import { import type { CourseSelectionStatus } from "./types" -function buildLotteryRankCase(ids: string[], startRank: number): SQL { +/** + * 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。 + */ +export function buildLotteryRankCase(ids: string[], startRank: number): SQL { const branches = ids.map( (id, idx) => sql`WHEN ${id} THEN ${startRank + idx}` ) diff --git a/src/modules/elective/types.ts b/src/modules/elective/types.ts index 4704b80..d5f894b 100644 --- a/src/modules/elective/types.ts +++ b/src/modules/elective/types.ts @@ -66,43 +66,8 @@ export interface GetElectiveCoursesParams { teacherId?: string } -export const ELECTIVE_STATUS_LABELS: Record = { - draft: "Draft", - open: "Open", - closed: "Closed", - cancelled: "Cancelled", -} - -export const ELECTIVE_STATUS_COLORS: Record< - ElectiveCourseStatus, - "default" | "secondary" | "destructive" | "outline" -> = { - draft: "secondary", - open: "default", - closed: "outline", - cancelled: "destructive", -} - -export const SELECTION_MODE_LABELS: Record = { - fcfs: "First Come First Served", - lottery: "Lottery", -} - -export const COURSE_SELECTION_STATUS_LABELS: Record = { - selected: "Selected", - enrolled: "Enrolled", - waitlist: "Waitlist", - dropped: "Dropped", - rejected: "Rejected", -} - -export const COURSE_SELECTION_STATUS_COLORS: Record< - CourseSelectionStatus, - "default" | "secondary" | "destructive" | "outline" -> = { - selected: "secondary", - enrolled: "default", - waitlist: "outline", - dropped: "destructive", - rejected: "destructive", -} +/** + * 注意:状态标签与颜色映射已迁移至 `./constants.ts`, + * 使用 i18n key(`status.*` / `selectionMode.*` / `selectionStatus.*`)+ Badge variant, + * 由组件层通过 `useTranslations("elective")` 解析。 + */ diff --git a/src/modules/parent/components/parent-attendance-calendar.tsx b/src/modules/parent/components/parent-attendance-calendar.tsx new file mode 100644 index 0000000..09dca06 --- /dev/null +++ b/src/modules/parent/components/parent-attendance-calendar.tsx @@ -0,0 +1,209 @@ +"use client" + +import { ChevronLeft, ChevronRight } from "lucide-react" +import { useState } from "react" +import { useTranslations } from "next-intl" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { cn } from "@/shared/lib/utils" +import { + ATTENDANCE_STATUS_DOT_COLORS, + ATTENDANCE_STATUS_LABEL_KEYS, +} from "@/modules/attendance/constants" +import type { + ParentAttendanceListItem, + ParentAttendanceStatus, + ParentStudentAttendanceSummary, +} from "@/modules/parent/types" + +const WEEKDAY_KEYS = [ + "parent.weekday.sun", + "parent.weekday.mon", + "parent.weekday.tue", + "parent.weekday.wed", + "parent.weekday.thu", + "parent.weekday.fri", + "parent.weekday.sat", +] as const + +/** + * 格式化日期为 `YYYY-MM-DD`(纯函数,便于测试)。 + */ +export function formatDateKey(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, "0") + const day = String(d.getDate()).padStart(2, "0") + return `${y}-${m}-${day}` +} + +/** + * 解析 `YYYY-MM-DD` 为 Date(纯函数,便于测试)。 + */ +export function parseDateKey(key: string): Date | null { + const parts = key.split("-") + if (parts.length !== 3) return null + const [y, m, d] = parts.map(Number) + if (!y || !m || !d) return null + return new Date(y, m - 1, d) +} + +/** + * 构建日历单元格(纯函数,便于测试)。 + */ +export function buildCalendarDays(year: number, month: number): Array { + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + const startWeekday = firstDay.getDay() + const totalDays = lastDay.getDate() + const cells: Array = [] + for (let i = 0; i < startWeekday; i += 1) cells.push(null) + for (let day = 1; day <= totalDays; day += 1) { + cells.push(new Date(year, month, day)) + } + while (cells.length % 7 !== 0) cells.push(null) + return cells +} + +/** + * 判断两个日期是否为同一天(纯函数,便于测试)。 + */ +export function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +/** + * 家长视角的考勤月历视图。 + * 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。 + */ +export function ParentAttendanceCalendar({ + summary, +}: { + summary: ParentStudentAttendanceSummary +}) { + const t = useTranslations("attendance") + const now = new Date() + const [viewYear, setViewYear] = useState(now.getFullYear()) + const [viewMonth, setViewMonth] = useState(now.getMonth()) + + const recordMap = new Map() + for (const r of summary.recentRecords) { + const d = parseDateKey(r.date) + if (d) recordMap.set(formatDateKey(d), r) + } + + const days = buildCalendarDays(viewYear, viewMonth) + const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + }) + + const goPrev = () => { + if (viewMonth === 0) { + setViewMonth(11) + setViewYear((y) => y - 1) + } else { + setViewMonth((m) => m - 1) + } + } + const goNext = () => { + if (viewMonth === 11) { + setViewMonth(0) + setViewYear((y) => y + 1) + } else { + setViewMonth((m) => m + 1) + } + } + + const usedStatuses = new Set() + for (const r of recordMap.values()) usedStatuses.add(r.status) + + return ( + + + + {t("parent.calendarFor", { name: summary.studentName })} +
+ + {monthLabel} + +
+
+
+ +
+ {WEEKDAY_KEYS.map((key) => ( +
+ {t(key)} +
+ ))} +
+
+ {days.map((d, idx) => { + if (!d) return
+ const key = formatDateKey(d) + const record = recordMap.get(key) + const isToday = isSameDay(d, now) + return ( +
+ {d.getDate()} + {record ? ( + + ) : null} +
+ ) + })} +
+ {usedStatuses.size > 0 ? ( +
+ {Array.from(usedStatuses).map((status) => ( + + + {t(ATTENDANCE_STATUS_LABEL_KEYS[status])} + + ))} +
+ ) : null} + + + ) +} diff --git a/src/modules/parent/components/parent-attendance-rate-card.tsx b/src/modules/parent/components/parent-attendance-rate-card.tsx new file mode 100644 index 0000000..042e496 --- /dev/null +++ b/src/modules/parent/components/parent-attendance-rate-card.tsx @@ -0,0 +1,130 @@ +"use client" + +import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Card } from "@/shared/components/ui/card" +import { cn } from "@/shared/lib/utils" +import type { ParentStudentAttendanceSummary } from "@/modules/parent/types" + +type AggregateStats = { + totalStudents: number + avgPresentRate: number + totalAbsent: number + totalLate: number +} + +/** + * 聚合多个子女的考勤统计(纯函数,便于测试)。 + */ +export function aggregateStats( + summaries: ParentStudentAttendanceSummary[], +): AggregateStats { + if (summaries.length === 0) { + return { totalStudents: 0, avgPresentRate: 0, totalAbsent: 0, totalLate: 0 } + } + const totalStudents = summaries.length + const sumRate = summaries.reduce( + (sum, s) => sum + (s.stats.total > 0 ? s.stats.presentRate : 0), + 0, + ) + const avgPresentRate = sumRate / totalStudents + const totalAbsent = summaries.reduce((sum, s) => sum + s.stats.absent, 0) + const totalLate = summaries.reduce((sum, s) => sum + s.stats.late, 0) + return { totalStudents, avgPresentRate, totalLate, totalAbsent } +} + +/** + * 根据出勤率返回语气色调(纯函数,便于测试)。 + */ +export function rateTone(rate: number): "good" | "warn" | "bad" { + if (rate >= 95) return "good" + if (rate >= 90) return "warn" + return "bad" +} + +const TONE_STYLES: Record<"good" | "warn" | "bad", string> = { + good: "text-emerald-600", + warn: "text-amber-600", + bad: "text-destructive", +} + +/** + * 家长考勤页顶部的出勤率汇总卡片。 + * 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。 + */ +export function ParentAttendanceRateCard({ + summaries, +}: { + summaries: ParentStudentAttendanceSummary[] +}) { + const t = useTranslations("attendance") + const stats = aggregateStats(summaries) + if (stats.totalStudents === 0) return null + + const tone = rateTone(stats.avgPresentRate) + const rateLabel = + stats.avgPresentRate >= 95 + ? t("parent.rateExcellent") + : stats.avgPresentRate >= 90 + ? t("parent.rateNeedsAttention") + : t("parent.rateBelowStandard") + + return ( + +
+
+
+ + {t("stats.attendanceRate")} +
+
+ {stats.avgPresentRate.toFixed(1)}% +
+
{rateLabel}
+
+ +
+
+ + {t("parent.children")} +
+
{stats.totalStudents}
+
{t("parent.linked")}
+
+ +
+
+ + {t("stats.absent")} +
+
0 && "text-destructive", + )} + > + {stats.totalAbsent} +
+
{t("parent.thisPeriod")}
+
+ +
+
+ + {t("stats.late")} +
+
0 && "text-amber-600", + )} + > + {stats.totalLate} +
+
{t("parent.thisPeriod")}
+
+
+
+ ) +} diff --git a/src/modules/parent/components/parent-attendance-warning.tsx b/src/modules/parent/components/parent-attendance-warning.tsx new file mode 100644 index 0000000..7e0910b --- /dev/null +++ b/src/modules/parent/components/parent-attendance-warning.tsx @@ -0,0 +1,115 @@ +"use client" + +import { AlertTriangle, Phone } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Card } from "@/shared/components/ui/card" +import { cn } from "@/shared/lib/utils" +import type { ParentStudentAttendanceSummary } from "@/modules/parent/types" + +type Warning = { + studentId: string + studentName: string + message: string + severity: "high" | "medium" +} + +/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */ +type Translator = ReturnType + +/** + * 构建考勤异常预警列表(纯函数,便于测试)。 + */ +export function buildWarnings( + summaries: ParentStudentAttendanceSummary[], + t: Translator, +): Warning[] { + const warnings: Warning[] = [] + + for (const s of summaries) { + const { stats, studentName, studentId } = s + if (stats.absent >= 3) { + warnings.push({ + studentId, + studentName, + message: t("parent.absentHighSeverity", { count: stats.absent }), + severity: "high", + }) + } else if (stats.absent >= 1) { + warnings.push({ + studentId, + studentName, + message: t("parent.absentWarning", { count: stats.absent }), + severity: "medium", + }) + } + + if (stats.late >= 3) { + warnings.push({ + studentId, + studentName, + message: t("parent.lateWarning", { count: stats.late }), + severity: "medium", + }) + } + + if (stats.presentRate < 90 && stats.total > 0) { + warnings.push({ + studentId, + studentName, + message: t("parent.lowRateWarning", { rate: stats.presentRate.toFixed(1) }), + severity: "high", + }) + } + } + + return warnings +} + +/** + * 家长视角的考勤异常预警横幅。 + * 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。 + */ +export function ParentAttendanceWarning({ + summaries, +}: { + summaries: ParentStudentAttendanceSummary[] +}) { + const t = useTranslations("attendance") + const warnings = buildWarnings(summaries, t) + if (warnings.length === 0) return null + + return ( + w.severity === "high") && "border-destructive/50", + )} + aria-label={t("parent.warningTitle")} + > +
+ +
+
+ {t("parent.warningTitle")} +
+
    + {warnings.map((w, idx) => ( +
  • + {w.studentName}: + {w.message} +
  • + ))} +
+
+ + {t("parent.contactHomeroom")} +
+
+
+
+ ) +} diff --git a/src/modules/parent/types.ts b/src/modules/parent/types.ts index 6a44888..140b798 100644 --- a/src/modules/parent/types.ts +++ b/src/modules/parent/types.ts @@ -39,6 +39,12 @@ export type ChildScheduleItem = { location: string | null } +/** 单条周课表项(含 weekday)。 */ +export type ChildWeeklyScheduleItem = ChildScheduleItem & { + /** 1=周一 ... 7=周日 */ + weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7 +} + /** 子女作业摘要(统计计数 + 最近作业列表)。 */ export type ChildHomeworkSummaryData = { pendingCount: number @@ -54,6 +60,8 @@ export type ChildDashboardData = { basicInfo: ChildBasicInfo enrolledClasses: StudentEnrolledClass[] todaySchedule: ChildScheduleItem[] + /** 完整周课表(按 weekday 升序)。 */ + weeklySchedule: ChildWeeklyScheduleItem[] homeworkSummary: ChildHomeworkSummaryData /** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */ gradeTrend: StudentDashboardGradeProps @@ -65,3 +73,45 @@ export type ParentDashboardData = { parentName: string | null children: ChildDashboardData[] } + +/* ------------------------------------------------------------------ */ +/* 考勤视图模型(解耦 parent 模块对 attendance/types 的直接依赖) */ +/* ------------------------------------------------------------------ */ + +/** + * 家长视角所需的考勤状态枚举。 + * 与 `attendance.AttendanceStatus` 结构兼容,但由 parent 模块自行声明, + * 避免 parent 反向依赖 attendance 模块的类型变更。 + */ +export type ParentAttendanceStatus = + | "present" + | "absent" + | "late" + | "early_leave" + | "excused" + +/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */ +export type ParentAttendanceListItem = { + id: string + date: string + status: ParentAttendanceStatus + remark: string | null +} + +/** 家长视角所需的考勤统计(仅保留展示字段)。 */ +export type ParentAttendanceStats = { + total: number + present: number + absent: number + late: number + presentRate: number +} + +/** 家长视角所需的单个子女考勤汇总。 */ +export type ParentStudentAttendanceSummary = { + studentId: string + studentName: string + stats: ParentAttendanceStats + recentRecords: ParentAttendanceListItem[] +} + diff --git a/src/shared/i18n/messages/en/attendance.json b/src/shared/i18n/messages/en/attendance.json index f562c13..6cd220d 100644 --- a/src/shared/i18n/messages/en/attendance.json +++ b/src/shared/i18n/messages/en/attendance.json @@ -31,7 +31,12 @@ "excused": "Excused", "attendanceRate": "Attendance Rate", "lateRate": "Late Rate", - "recentRecords": "Recent Records" + "recentRecords": "Recent Records", + "studentRecords": "Student Records", + "noClasses": "No classes", + "noClassesDescription": "You don't have any classes yet.", + "noData": "No data", + "noDataDescription": "No attendance data available for this class." }, "filters": { "class": "Class", @@ -66,7 +71,10 @@ "selectClass": "Select Class", "selectDate": "Select Date", "noStudents": "No students in this class", + "description": "Select a class and date, then mark attendance for each student.", "confirmDelete": "Are you sure you want to delete this attendance record?", + "confirmClassSwitch": "Switching class will discard unsaved changes. Continue?", + "confirmClassSwitchAction": "Switch Class", "saved": "Attendance saved", "updated": "Attendance updated", "deleted": "Attendance record deleted" @@ -86,10 +94,34 @@ "parent": { "warningTitle": "Attendance Warnings", "rateCardTitle": "Attendance Rate Summary", - "calendarTitle": "Attendance Calendar", + "calendarTitle": "Attendance calendar for {name}", + "calendarFor": "{name}'s Calendar", + "prevMonth": "Previous month", + "nextMonth": "Next month", "noWarnings": "No attendance warnings", "absentWarning": "{count} absence(s)", + "absentHighSeverity": "{count} absences recorded. Consider contacting the homeroom teacher.", "lateWarning": "{count} late arrival(s)", - "lowRateWarning": "Attendance rate {rate}% below threshold" + "lowRateWarning": "Attendance rate {rate}% below threshold", + "contactHomeroom": "Consider contacting the homeroom teacher for details.", + "rateExcellent": "Excellent", + "rateNeedsAttention": "Needs attention", + "rateBelowStandard": "Below standard", + "children": "Children", + "linked": "linked", + "thisPeriod": "this period", + "noChildrenTitle": "No children linked", + "noChildrenDescription": "Your account is not linked to any student accounts yet. Please contact the school administrator.", + "compareDescription": "Compare attendance across all your children. For single-child details, open the child's detail page.", + "noRecordsDescription": "Your children don't have any attendance records yet.", + "weekday": { + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" + } } } diff --git a/src/shared/i18n/messages/en/elective.json b/src/shared/i18n/messages/en/elective.json index 90a8d12..d743366 100644 --- a/src/shared/i18n/messages/en/elective.json +++ b/src/shared/i18n/messages/en/elective.json @@ -8,6 +8,8 @@ }, "description": { "adminList": "Manage elective courses, open/close selection and lottery.", + "create": "Create a new elective course.", + "edit": "Update elective course details.", "teacher": "View and manage the elective courses you teach.", "student": "Browse available courses and make selections." }, @@ -21,6 +23,9 @@ "fcfs": "First Come First Served", "lottery": "Lottery" }, + "filters": { + "allStatuses": "All Modes" + }, "selectionStatus": { "selected": "Selected", "enrolled": "Enrolled", diff --git a/src/shared/i18n/messages/zh-CN/attendance.json b/src/shared/i18n/messages/zh-CN/attendance.json index d78c02c..0450d04 100644 --- a/src/shared/i18n/messages/zh-CN/attendance.json +++ b/src/shared/i18n/messages/zh-CN/attendance.json @@ -31,7 +31,12 @@ "excused": "请假", "attendanceRate": "出勤率", "lateRate": "迟到率", - "recentRecords": "最近记录" + "recentRecords": "最近记录", + "studentRecords": "学生记录", + "noClasses": "暂无班级", + "noClassesDescription": "您目前没有任何班级。", + "noData": "暂无数据", + "noDataDescription": "该班级暂无考勤数据。" }, "filters": { "class": "班级", @@ -66,7 +71,10 @@ "selectClass": "选择班级", "selectDate": "选择日期", "noStudents": "该班级暂无学生", + "description": "选择班级和日期,然后为每位学生标记考勤。", "confirmDelete": "确定删除此条考勤记录吗?", + "confirmClassSwitch": "切换班级将丢弃未保存的修改,是否继续?", + "confirmClassSwitchAction": "切换班级", "saved": "考勤已保存", "updated": "考勤已更新", "deleted": "考勤记录已删除" @@ -86,10 +94,34 @@ "parent": { "warningTitle": "考勤异常预警", "rateCardTitle": "出勤率汇总", - "calendarTitle": "考勤月历", + "calendarTitle": "{name} 的考勤月历", + "calendarFor": "{name} 的月历", + "prevMonth": "上个月", + "nextMonth": "下个月", "noWarnings": "暂无考勤异常", "absentWarning": "{count} 次缺勤", + "absentHighSeverity": "已记录 {count} 次缺勤,建议联系班主任了解情况。", "lateWarning": "{count} 次迟到", - "lowRateWarning": "出勤率 {rate}% 低于阈值" + "lowRateWarning": "出勤率 {rate}% 低于阈值", + "contactHomeroom": "如需了解详情,请联系班主任。", + "rateExcellent": "优秀", + "rateNeedsAttention": "需要关注", + "rateBelowStandard": "未达标", + "children": "子女", + "linked": "已关联", + "thisPeriod": "本周期", + "noChildrenTitle": "尚未关联子女", + "noChildrenDescription": "您的账号尚未关联任何学生账号,请联系学校管理员。", + "compareDescription": "对比所有子女的考勤情况。如需查看单个子女详情,请进入子女详情页。", + "noRecordsDescription": "您的子女暂无任何考勤记录。", + "weekday": { + "sun": "周日", + "mon": "周一", + "tue": "周二", + "wed": "周三", + "thu": "周四", + "fri": "周五", + "sat": "周六" + } } } diff --git a/src/shared/i18n/messages/zh-CN/elective.json b/src/shared/i18n/messages/zh-CN/elective.json index 5d2532d..07f4bd1 100644 --- a/src/shared/i18n/messages/zh-CN/elective.json +++ b/src/shared/i18n/messages/zh-CN/elective.json @@ -8,6 +8,8 @@ }, "description": { "adminList": "管理选修课程、开放/关闭选课与抽签。", + "create": "创建新的选修课程。", + "edit": "更新选修课程详情。", "teacher": "查看和管理您教授的选修课程。", "student": "浏览可选课程并进行选课。" }, @@ -21,6 +23,9 @@ "fcfs": "先到先得", "lottery": "抽签" }, + "filters": { + "allStatuses": "全部模式" + }, "selectionStatus": { "selected": "已选", "enrolled": "已录取", diff --git a/src/shared/lib/track-event.ts b/src/shared/lib/track-event.ts new file mode 100644 index 0000000..51061a0 --- /dev/null +++ b/src/shared/lib/track-event.ts @@ -0,0 +1,92 @@ +import "server-only" + +/** + * 监控埋点接口(预留) + * + * 在关键 Server Action 中调用 trackEvent 记录业务事件,用于: + * - 公告阅读率、消息回复率等关键指标统计 + * - 通知发送失败告警 + * - 用户行为漏斗分析 + * + * 当前实现:输出到 console.info,不阻塞主流程。 + * 后续扩展:可接入外部监控服务(如 Sentry / PostHog / 自建埋点系统), + * 只需在 trackEventToSink 中替换实现即可,调用方无需改动。 + */ + +/** 事件名称(使用点号分隔的命名空间,如 "announcement.published") */ +export type EventName = + | "announcement.created" + | "announcement.updated" + | "announcement.published" + | "announcement.archived" + | "announcement.deleted" + | "message.sent" + | "message.deleted" + | "message.marked_read" + | "notification.marked_read" + | "notification.marked_all_read" + | "notification.sent" + | "notification.send_failed" + | "attendance.recorded" + | "attendance.batch_recorded" + | "attendance.updated" + | "attendance.deleted" + | "attendance.rules_saved" + | "elective.course_created" + | "elective.course_updated" + | "elective.course_deleted" + | "elective.selection_opened" + | "elective.selection_closed" + | "elective.course_selected" + | "elective.course_dropped" + | "elective.lottery_completed" + +/** 埋点事件负载 */ +export interface TrackEventPayload { + /** 事件名称 */ + event: EventName + /** 当前用户 ID(可选,未登录场景为 undefined) */ + userId?: string + /** 目标对象 ID(如公告 ID、消息 ID) */ + targetId?: string + /** 目标对象类型(如 "announcement"、"message") */ + targetType?: string + /** 附加属性(如受众人数、渠道类型) */ + properties?: Record +} + +/** + * 将事件发送到外部监控服务。 + * + * 当前为占位实现:仅输出到 console.info。 + * 接入真实服务时替换此函数体即可。 + */ +function trackEventToSink(payload: TrackEventPayload): void { + console.info( + `[TrackEvent] ${payload.event} userId=${payload.userId ?? "-"} targetId=${payload.targetId ?? "-"}${payload.properties ? ` properties=${JSON.stringify(payload.properties)}` : ""}` + ) +} + +/** + * 记录一个监控事件。 + * + * 非阻塞:任何异常都被吞掉,确保不影响主业务流程。 + * + * @example + * ```ts + * await trackEvent({ + * event: "announcement.published", + * userId: ctx.userId, + * targetId: id, + * targetType: "announcement", + * properties: { audienceSize: userIds.length }, + * }) + * ``` + */ +export async function trackEvent(payload: TrackEventPayload): Promise { + try { + trackEventToSink(payload) + } catch { + // 埋点失败不影响主流程 + } +}