From 37d2688a28b15d1ed9e35539287ddcc34820fb68 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:03:47 +0800 Subject: [PATCH] feat(app): add lesson-plans, practice, and grade dashboard routes - Add admin/lesson-plans, parent/lesson-plans, student/lesson-plans routes - Add student/practice and teacher/practice routes for adaptive practice - Add management/grade/dashboard and management/grade/practice routes - Add teacher/lesson-plans error and loading boundaries - Update existing admin, parent, student, teacher pages with new features - Update globals.css and proxy middleware --- .../(dashboard)/admin/ai-settings/page.tsx | 30 +- .../admin/audit-logs/data-changes/page.tsx | 19 +- .../admin/audit-logs/login-logs/page.tsx | 17 +- src/app/(dashboard)/admin/audit-logs/page.tsx | 19 +- .../admin/course-plans/[id]/edit/page.tsx | 15 +- .../admin/course-plans/[id]/page.tsx | 10 +- .../admin/course-plans/create/page.tsx | 17 +- .../(dashboard)/admin/course-plans/page.tsx | 19 +- src/app/(dashboard)/admin/error-book/page.tsx | 192 +++++++++--- src/app/(dashboard)/admin/files/page.tsx | 12 +- src/app/(dashboard)/admin/layout.tsx | 3 - .../lesson-plans/[planId]/view/error.tsx | 20 ++ .../lesson-plans/[planId]/view/loading.tsx | 11 + .../admin/lesson-plans/[planId]/view/page.tsx | 62 ++++ .../(dashboard)/admin/lesson-plans/error.tsx | 20 ++ .../admin/lesson-plans/loading.tsx | 22 ++ .../(dashboard)/admin/lesson-plans/page.tsx | 88 ++++++ .../admin/scheduling/auto/page.tsx | 27 +- .../admin/scheduling/changes/page.tsx | 37 ++- .../admin/scheduling/rules/page.tsx | 25 +- .../admin/school/grades/insights/page.tsx | 222 +++++++------- .../(dashboard)/admin/school/grades/page.tsx | 11 +- src/app/(dashboard)/admin/settings/page.tsx | 10 +- .../(dashboard)/admin/users/import/page.tsx | 87 +++--- src/app/(dashboard)/admin/users/page.tsx | 10 +- .../management/grade/dashboard/loading.tsx | 42 +++ .../management/grade/dashboard/page.tsx | 178 ++++++++++++ .../management/grade/insights/page.tsx | 214 +++++++------- .../management/grade/practice/error.tsx | 27 ++ .../management/grade/practice/loading.tsx | 16 + .../management/grade/practice/page.tsx | 141 +++++++++ .../parent/children/[studentId]/page.tsx | 6 +- .../(dashboard)/parent/diagnostic/page.tsx | 24 +- .../(dashboard)/parent/error-book/page.tsx | 30 +- src/app/(dashboard)/parent/error.tsx | 7 +- src/app/(dashboard)/parent/grades/page.tsx | 18 +- src/app/(dashboard)/parent/leave/page.tsx | 25 +- .../lesson-plans/[planId]/view/error.tsx | 20 ++ .../lesson-plans/[planId]/view/loading.tsx | 11 + .../lesson-plans/[planId]/view/page.tsx | 81 ++++++ .../(dashboard)/parent/lesson-plans/error.tsx | 20 ++ .../parent/lesson-plans/loading.tsx | 17 ++ .../(dashboard)/parent/lesson-plans/page.tsx | 44 +++ .../(dashboard)/student/attendance/error.tsx | 2 +- .../(dashboard)/student/diagnostic/error.tsx | 8 +- .../(dashboard)/student/diagnostic/page.tsx | 7 +- .../(dashboard)/student/elective/error.tsx | 2 +- .../(dashboard)/student/error-book/error.tsx | 8 +- .../(dashboard)/student/error-book/page.tsx | 6 +- src/app/(dashboard)/student/error.tsx | 11 +- src/app/(dashboard)/student/grades/error.tsx | 8 +- src/app/(dashboard)/student/grades/page.tsx | 4 +- .../assignments/[assignmentId]/page.tsx | 9 +- .../learning/courses/[classId]/page.tsx | 57 ++-- .../student/learning/courses/page.tsx | 14 +- src/app/(dashboard)/student/learning/page.tsx | 29 +- .../lesson-plans/[planId]/view/error.tsx | 20 ++ .../lesson-plans/[planId]/view/loading.tsx | 11 + .../lesson-plans/[planId]/view/page.tsx | 83 ++++++ .../student/lesson-plans/error.tsx | 20 ++ .../student/lesson-plans/loading.tsx | 17 ++ .../(dashboard)/student/lesson-plans/page.tsx | 44 +++ .../student/practice/[sessionId]/error.tsx | 32 ++ .../student/practice/[sessionId]/loading.tsx | 14 + .../student/practice/[sessionId]/page.tsx | 31 ++ .../(dashboard)/student/practice/error.tsx | 26 ++ .../(dashboard)/student/practice/loading.tsx | 25 ++ src/app/(dashboard)/student/practice/page.tsx | 45 +++ src/app/(dashboard)/student/schedule/page.tsx | 16 +- .../(dashboard)/teacher/error-book/page.tsx | 193 +++++++++++-- .../(dashboard)/teacher/grades/entry/page.tsx | 94 +++--- .../lesson-plans/[planId]/edit/error.tsx | 20 ++ .../lesson-plans/[planId]/edit/loading.tsx | 11 + .../lesson-plans/[planId]/edit/page.tsx | 44 +-- .../teacher/lesson-plans/error.tsx | 20 ++ .../teacher/lesson-plans/loading.tsx | 25 ++ .../teacher/lesson-plans/new/error.tsx | 20 ++ .../teacher/lesson-plans/new/loading.tsx | 20 ++ .../teacher/lesson-plans/new/page.tsx | 29 +- .../(dashboard)/teacher/lesson-plans/page.tsx | 37 +-- .../(dashboard)/teacher/practice/error.tsx | 27 ++ .../(dashboard)/teacher/practice/loading.tsx | 17 ++ src/app/(dashboard)/teacher/practice/page.tsx | 273 ++++++++++++++++++ src/app/globals.css | 21 ++ 84 files changed, 2665 insertions(+), 661 deletions(-) create mode 100644 src/app/(dashboard)/admin/lesson-plans/[planId]/view/error.tsx create mode 100644 src/app/(dashboard)/admin/lesson-plans/[planId]/view/loading.tsx create mode 100644 src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx create mode 100644 src/app/(dashboard)/admin/lesson-plans/error.tsx create mode 100644 src/app/(dashboard)/admin/lesson-plans/loading.tsx create mode 100644 src/app/(dashboard)/admin/lesson-plans/page.tsx create mode 100644 src/app/(dashboard)/management/grade/dashboard/loading.tsx create mode 100644 src/app/(dashboard)/management/grade/dashboard/page.tsx create mode 100644 src/app/(dashboard)/management/grade/practice/error.tsx create mode 100644 src/app/(dashboard)/management/grade/practice/loading.tsx create mode 100644 src/app/(dashboard)/management/grade/practice/page.tsx create mode 100644 src/app/(dashboard)/parent/lesson-plans/[planId]/view/error.tsx create mode 100644 src/app/(dashboard)/parent/lesson-plans/[planId]/view/loading.tsx create mode 100644 src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx create mode 100644 src/app/(dashboard)/parent/lesson-plans/error.tsx create mode 100644 src/app/(dashboard)/parent/lesson-plans/loading.tsx create mode 100644 src/app/(dashboard)/parent/lesson-plans/page.tsx create mode 100644 src/app/(dashboard)/student/lesson-plans/[planId]/view/error.tsx create mode 100644 src/app/(dashboard)/student/lesson-plans/[planId]/view/loading.tsx create mode 100644 src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx create mode 100644 src/app/(dashboard)/student/lesson-plans/error.tsx create mode 100644 src/app/(dashboard)/student/lesson-plans/loading.tsx create mode 100644 src/app/(dashboard)/student/lesson-plans/page.tsx create mode 100644 src/app/(dashboard)/student/practice/[sessionId]/error.tsx create mode 100644 src/app/(dashboard)/student/practice/[sessionId]/loading.tsx create mode 100644 src/app/(dashboard)/student/practice/[sessionId]/page.tsx create mode 100644 src/app/(dashboard)/student/practice/error.tsx create mode 100644 src/app/(dashboard)/student/practice/loading.tsx create mode 100644 src/app/(dashboard)/student/practice/page.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/error.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/loading.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/error.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/loading.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/new/error.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/new/loading.tsx create mode 100644 src/app/(dashboard)/teacher/practice/error.tsx create mode 100644 src/app/(dashboard)/teacher/practice/loading.tsx create mode 100644 src/app/(dashboard)/teacher/practice/page.tsx diff --git a/src/app/(dashboard)/admin/ai-settings/page.tsx b/src/app/(dashboard)/admin/ai-settings/page.tsx index c8019db..86f88cc 100644 --- a/src/app/(dashboard)/admin/ai-settings/page.tsx +++ b/src/app/(dashboard)/admin/ai-settings/page.tsx @@ -1,14 +1,18 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card" import { AiUsageDashboard } from "@/modules/ai/components/ai-usage-dashboard" -export const metadata: Metadata = { - title: "AI 配置 - Next_Edu", - description: "统一管理 AI 服务商、API 密钥与使用统计", +export async function generateMetadata(): Promise { + const t = await getTranslations("ai") + return { + title: `${t("admin.settings.title")} - Next_Edu`, + description: t("admin.settings.description"), + } } export const dynamic = "force-dynamic" @@ -18,25 +22,31 @@ export const dynamic = "force-dynamic" * * 作为 AI 模块的独立配置入口,取代: * - /settings?tab=ai(已移除 AI 标签页) - * - 考试页面内嵌的 AI 配置弹窗(已改为跳转链接) + * - 考试页面内嵌的 AI 配置弹窗(已移除) * - * 权限:AI_CONFIGURE(当前仅 admin 角色拥有) + * 权限规则: + * - AI_CHAT 用户均可访问(管理自己的 private provider) + * - AI_CONFIGURE 用户(管理员)可额外管理 public provider 与他人 private provider */ export default async function AiSettingsPage(): Promise { - await requirePermission(Permissions.AI_CONFIGURE) + const t = await getTranslations("ai") + const ctx = await requirePermission(Permissions.AI_CHAT) + const isAdmin = ctx.permissions.includes(Permissions.AI_CONFIGURE) return (
-

AI 配置

+

{t("admin.settings.title")}

- 统一管理 AI 服务商、API 密钥与使用统计 + {isAdmin + ? t("admin.settings.description") + : t("admin.settings.userDescription")}
- - + + {isAdmin ? : null}
) diff --git a/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx b/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx index bd2aa3a..fc8ce12 100644 --- a/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx +++ b/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx @@ -1,5 +1,6 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -13,9 +14,12 @@ import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-t import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button" import type { DataChangeAction } from "@/modules/audit/types" -export const metadata: Metadata = { - title: "数据变更日志 - Next_Edu", - description: "追踪系统所有数据变更(增删改),保障合规", +export async function generateMetadata(): Promise { + const t = await getTranslations("audit") + return { + title: `${t("dataChanges.title")} - Next_Edu`, + description: t("dataChanges.description"), + } } export const dynamic = "force-dynamic" @@ -28,6 +32,7 @@ export default async function DataChangeLogsPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("audit") await requirePermission(Permissions.AUDIT_LOG_READ) const params = await searchParams @@ -54,10 +59,8 @@ export default async function DataChangeLogsPage({
-

数据变更日志

-

- 追踪系统所有数据变更(增删改),保障合规。 -

+

{t("dataChanges.title")}

+

{t("dataChanges.description")}

diff --git a/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx b/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx index 8e29679..ebdca58 100644 --- a/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx +++ b/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx @@ -1,5 +1,6 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -9,9 +10,12 @@ import { LoginLogView } from "@/modules/audit/components/login-log-view" import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button" import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types" -export const metadata: Metadata = { - title: "登录日志 - Next_Edu", - description: "监控所有认证事件,包括登录、登出与注册", +export async function generateMetadata(): Promise { + const t = await getTranslations("audit") + return { + title: `${t("loginLogs.title")} - Next_Edu`, + description: t("loginLogs.description"), + } } export const dynamic = "force-dynamic" @@ -27,6 +31,7 @@ export default async function LoginLogsPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("audit") await requirePermission(Permissions.AUDIT_LOG_READ) const params = await searchParams @@ -50,9 +55,9 @@ export default async function LoginLogsPage({
-

登录日志

+

{t("loginLogs.title")}

- 监控所有认证事件,包括登录、登出与注册。 + {t("loginLogs.description")}

diff --git a/src/app/(dashboard)/admin/audit-logs/page.tsx b/src/app/(dashboard)/admin/audit-logs/page.tsx index 9e790b4..56dc206 100644 --- a/src/app/(dashboard)/admin/audit-logs/page.tsx +++ b/src/app/(dashboard)/admin/audit-logs/page.tsx @@ -1,5 +1,6 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -9,9 +10,12 @@ import { AuditLogView } from "@/modules/audit/components/audit-log-view" import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button" import type { AuditLogStatus } from "@/modules/audit/types" -export const metadata: Metadata = { - title: "审计日志 - Next_Edu", - description: "追踪系统内所有用户操作,保障安全与合规", +export async function generateMetadata(): Promise { + const t = await getTranslations("audit") + return { + title: `${t("title")} - Next_Edu`, + description: t("description"), + } } export const dynamic = "force-dynamic" @@ -24,6 +28,7 @@ export default async function AuditLogsPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("audit") await requirePermission(Permissions.AUDIT_LOG_READ) const params = await searchParams @@ -51,10 +56,8 @@ export default async function AuditLogsPage({
-

审计日志

-

- 追踪系统内所有用户操作,保障安全与合规。 -

+

{t("title")}

+

{t("description")}

diff --git a/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx b/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx index f2e9a77..c458849 100644 --- a/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx +++ b/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx @@ -1,15 +1,19 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getCoursePlanById, getSubjectOptions } from "@/modules/course-plans/data-access" import { getAdminClasses } from "@/modules/classes/data-access" import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access" import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form" -export const metadata: Metadata = { - title: "编辑课程计划 - Next_Edu", - description: "更新课程计划详情", +export async function generateMetadata(): Promise { + const t = await getTranslations("coursePlans") + return { + title: `${t("edit.title")} - Next_Edu`, + description: t("edit.description"), + } } export const dynamic = "force-dynamic" @@ -19,6 +23,7 @@ export default async function EditCoursePlanPage({ }: { params: Promise<{ id: string }> }): Promise { + const t = await getTranslations("coursePlans") const { id } = await params const [plan, classes, subjects, teachers, academicYears] = await Promise.all([ @@ -34,8 +39,8 @@ export default async function EditCoursePlanPage({ return (
-

编辑课程计划

-

更新课程计划详情。

+

{t("edit.title")}

+

{t("edit.description")}

{ + const t = await getTranslations("coursePlans") + return { + title: `${t("detail.title")} - Next_Edu`, + description: t("detail.description"), + } } export const dynamic = "force-dynamic" diff --git a/src/app/(dashboard)/admin/course-plans/create/page.tsx b/src/app/(dashboard)/admin/course-plans/create/page.tsx index 7764db5..f98faab 100644 --- a/src/app/(dashboard)/admin/course-plans/create/page.tsx +++ b/src/app/(dashboard)/admin/course-plans/create/page.tsx @@ -1,19 +1,24 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { getAdminClasses } from "@/modules/classes/data-access" import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access" import { getSubjectOptions } from "@/modules/course-plans/data-access" import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form" -export const metadata: Metadata = { - title: "新建课程计划 - Next_Edu", - description: "创建新的课程教学计划", +export async function generateMetadata(): Promise { + const t = await getTranslations("coursePlans") + return { + title: `${t("create.title")} - Next_Edu`, + description: t("create.description"), + } } export const dynamic = "force-dynamic" export default async function CreateCoursePlanPage(): Promise { + const t = await getTranslations("coursePlans") const [classes, subjects, teachers, academicYears] = await Promise.all([ getAdminClasses(), getSubjectOptions(), @@ -24,8 +29,8 @@ export default async function CreateCoursePlanPage(): Promise { return (
-

新建课程计划

-

创建新的课程教学计划。

+

{t("create.title")}

+

{t("create.description")}

{ + const t = await getTranslations("coursePlans") + return { + title: `${t("title")} - Next_Edu`, + description: t("description"), + } } export const dynamic = "force-dynamic" @@ -21,6 +25,7 @@ export default async function AdminCoursePlansPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("coursePlans") const sp = await searchParams const statusParam = getSearchParam(sp, "status") const status = isValidStatus(statusParam) ? statusParam : undefined @@ -30,10 +35,8 @@ export default async function AdminCoursePlansPage({ return (
-

课程计划

-

- 管理课程教学计划与周课时安排。 -

+

{t("title")}

+

{t("description")}

{ +export async function generateMetadata(): Promise { + const t = await getTranslations("errorBook") + return { + title: `${t("admin.title")} - Next_Edu`, + description: t("admin.description"), + } +} + +async function AdminErrorBookContent({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const t = await getTranslations("errorBook") const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ) if (ctx.dataScope.type !== "all") { return (
-

全校错题分析

-

查看全校学生的错题统计与薄弱知识点。

+

{t("admin.title")}

+

{t("admin.description")}

) } - // 通过 data-access 层查询所有学生 ID(遵循三层架构,app 层不直接访问 DB) - const studentIds = await getAllStudentIds() + const params = await searchParams - if (studentIds.length === 0) { + // 通过 data-access 层查询所有学生 ID(遵循三层架构) + const allStudentIds = await getAllStudentIds() + + if (allStudentIds.length === 0) { return (
-

全校错题分析

-

查看全校学生的错题统计与薄弱知识点。

+

{t("admin.title")}

+

{t("admin.description")}

@@ -59,21 +86,33 @@ export default async function AdminErrorBookPage(): Promise { } // 限制查询数量,避免性能问题(取最近活跃的 500 名学生) - const limitedStudentIds = studentIds.slice(0, 500) + const limitedStudentIds = allStudentIds.slice(0, 500) - const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([ - getStudentErrorBookSummaries(limitedStudentIds), - getTopWrongQuestionsByStudentIds(limitedStudentIds, 10), - getKnowledgePointWeakness(limitedStudentIds, 10), + // 解析 URL 参数:学科筛选 + const subjectParam = getParam(params, "subject") + + // 学科概览(用于 Tab 显示,不受学科筛选影响) + const [subjectOverviews, subjectDist] = await Promise.all([ + getSubjectErrorOverviews(limitedStudentIds), getSubjectErrorDistribution(limitedStudentIds), + ]) + + // 并行查询所有统计数据(按学科过滤) + const [summaries, topWrongQuestions, weakKps, chapterWeakness, nameMap] = await Promise.all([ + getStudentErrorBookSummaries(limitedStudentIds, subjectParam), + getTopWrongQuestionsByStudentIds(limitedStudentIds, 10, subjectParam), + getKnowledgePointWeakness(limitedStudentIds, 10, subjectParam), + getChapterWeakness(limitedStudentIds, 10, subjectParam), getStudentNameMap(limitedStudentIds), ]) const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0) const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0) + const totalDueReview = summaries.reduce((sum, s) => sum + s.dueReviewCount, 0) const averageMasteryRate = studentsWithErrorBook.length > 0 ? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length : 0 + const knowledgePointCount = weakKps.length const sortedSummaries = [...summaries] .filter((s) => s.totalCount > 0) @@ -81,33 +120,114 @@ export default async function AdminErrorBookPage(): Promise { .slice(0, 50) return ( -
+
-

全校错题分析

-

- 全校错题统计与薄弱知识点分析,辅助教学决策。 -

+

{t("admin.title")}

+

{t("admin.description")}

- 0 ? ( + }> + + + ) : null} + + {/* 统计卡片 */} + -
-

错题最多的学生 Top 50

- + {/* 学科错题分布图(仅在"全部学科"视图下显示) */} + {!subjectParam && subjectDist.length > 0 ? ( + + ) : null} + + {/* 章节错题分布 + 知识点薄弱度(并排) */} +
+ {chapterWeakness.length > 0 ? ( + + ) : ( + + )} + {weakKps.length > 0 ? ( + + ) : ( + + )}
- + {/* 错题最多的学生 Top 50 */} +
+
+

{t("admin.topStudents")}

+ + {t("admin.studentsWithErrors", { count: studentsWithErrorBook.length })} + +
+ {sortedSummaries.length > 0 ? ( + + ) : ( + + )} +
+ + {/* 高频错题 Top 10 */} + {topWrongQuestions.length > 0 ? ( + + ) : null}
) } + +export default async function AdminErrorBookPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + return ( + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ } + > + + + ) +} diff --git a/src/app/(dashboard)/admin/files/page.tsx b/src/app/(dashboard)/admin/files/page.tsx index a8650cb..e0f6e86 100644 --- a/src/app/(dashboard)/admin/files/page.tsx +++ b/src/app/(dashboard)/admin/files/page.tsx @@ -1,5 +1,6 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -9,9 +10,12 @@ import { } from "@/modules/files/data-access" import { AdminFilesView } from "@/modules/files/components/admin-files-view" -export const metadata: Metadata = { - title: "文件管理 - Next_Edu", - description: "查看与管理系统中所有上传文件", +export async function generateMetadata(): Promise { + const t = await getTranslations("files") + return { + title: `${t("title")} - Next_Edu`, + description: t("description"), + } } export const dynamic = "force-dynamic" diff --git a/src/app/(dashboard)/admin/layout.tsx b/src/app/(dashboard)/admin/layout.tsx index d712a5b..38b196d 100644 --- a/src/app/(dashboard)/admin/layout.tsx +++ b/src/app/(dashboard)/admin/layout.tsx @@ -1,10 +1,7 @@ -import { getAuthContext } from "@/shared/lib/auth-guard" - export default async function AdminLayout({ children, }: { children: React.ReactNode }): Promise { - await getAuthContext() return <>{children} } diff --git a/src/app/(dashboard)/admin/lesson-plans/[planId]/view/error.tsx b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/error.tsx new file mode 100644 index 0000000..9b8e4d4 --- /dev/null +++ b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminLessonPlanViewError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/lesson-plans/[planId]/view/loading.tsx b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/loading.tsx new file mode 100644 index 0000000..4d6eaf0 --- /dev/null +++ b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminLessonPlanViewLoading() { + return ( +
+
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx new file mode 100644 index 0000000..3a09080 --- /dev/null +++ b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx @@ -0,0 +1,62 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { notFound } from "next/navigation" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export const dynamic = "force-dynamic" + +export default async function AdminLessonPlanViewPage({ + params, +}: { + params: Promise<{ planId: string }> +}): Promise { + const { planId } = await params + const ctx = await getAuthContext() + + const plan = await getLessonPlanById(planId, ctx.userId) + if (!plan) notFound() + + let textbookTitle: string | undefined + let chapterTitle: string | undefined + if (plan.textbookId) { + const textbook = await getTextbookById(plan.textbookId) + textbookTitle = textbook?.title + if (plan.chapterId) { + const chapters = await getChaptersByTextbookId(plan.textbookId) + const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { + for (const ch of list) { + if (ch.id === plan.chapterId) return ch + if (ch.children && ch.children.length > 0) { + const found = findChapter(ch.children as typeof chapters) + if (found) return found + } + } + return undefined + } + const chapter = findChapter(chapters) + chapterTitle = chapter?.title + } + } + + return ( +
+ + +
+ } + > + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/lesson-plans/error.tsx b/src/app/(dashboard)/admin/lesson-plans/error.tsx new file mode 100644 index 0000000..d3d74e9 --- /dev/null +++ b/src/app/(dashboard)/admin/lesson-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminLessonPlansError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/lesson-plans/loading.tsx b/src/app/(dashboard)/admin/lesson-plans/loading.tsx new file mode 100644 index 0000000..5e8e257 --- /dev/null +++ b/src/app/(dashboard)/admin/lesson-plans/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminLessonPlansLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/lesson-plans/page.tsx b/src/app/(dashboard)/admin/lesson-plans/page.tsx new file mode 100644 index 0000000..53f89f9 --- /dev/null +++ b/src/app/(dashboard)/admin/lesson-plans/page.tsx @@ -0,0 +1,88 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { getTranslations } from "next-intl/server" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getLessonPlans, getLessonPlanStats } from "@/modules/lesson-preparation/data-access" +import { getSubjectOptions } from "@/modules/school/data-access" +import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export const dynamic = "force-dynamic" + +export default async function AdminLessonPlansPage(): Promise { + const t = await getTranslations("lessonPreparation") + const ctx = await getAuthContext() + + // 通过 data-access 层查询,避免 app 层直接访问数据库(P0-1 修复) + const [items, subjects, stats] = await Promise.all([ + getLessonPlans({}, ctx.dataScope, ctx.userId), + getSubjectOptions(), + getLessonPlanStats(), + ]) + + return ( +
+
+

{t("admin.title")}

+

{t("admin.description")}

+
+ + {/* 统计卡片 */} +
+ + + + {t("admin.stats.total")} + + + +
{stats.total}
+
+
+ + + + {t("admin.stats.published")} + + + +
{stats.published}
+
+
+ + + + {t("admin.stats.draft")} + + + +
{stats.draft}
+
+
+ + + + {t("admin.stats.archived")} + + + +
{stats.archived}
+
+
+
+ + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ } + > + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/auto/page.tsx b/src/app/(dashboard)/admin/scheduling/auto/page.tsx index 7349838..d1db7e9 100644 --- a/src/app/(dashboard)/admin/scheduling/auto/page.tsx +++ b/src/app/(dashboard)/admin/scheduling/auto/page.tsx @@ -1,7 +1,8 @@ -import Link from "next/link" +import Link from "next/link" import { CalendarClock, ClipboardList, Settings2 } from "lucide-react" import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -10,14 +11,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import { getAdminClassesForScheduling } from "@/modules/scheduling/data-access" import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel" -export const metadata: Metadata = { - title: "自动排课 - Next_Edu", - description: "基于规则与学科分配自动生成周课表", +export async function generateMetadata(): Promise { + const t = await getTranslations("scheduling") + return { + title: `${t("auto.title")} - Next_Edu`, + description: t("auto.description"), + } } export const dynamic = "force-dynamic" export default async function AdminSchedulingAutoPage(): Promise { + const t = await getTranslations("scheduling") await requirePermission(Permissions.SCHEDULE_AUTO) const classes = await getAdminClassesForScheduling() const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade })) @@ -26,15 +31,13 @@ export default async function AdminSchedulingAutoPage(): Promise {
-

自动排课

-

- 基于规则与学科分配自动生成周课表。 -

+

{t("auto.title")}

+

{t("auto.description")}

@@ -42,8 +45,8 @@ export default async function AdminSchedulingAutoPage(): Promise { {classOptions.length === 0 ? ( ) : ( @@ -51,7 +54,7 @@ export default async function AdminSchedulingAutoPage(): Promise {
- 应用新课表将替换所选班级的现有课表。 + {t("auto.hint")}
) diff --git a/src/app/(dashboard)/admin/scheduling/changes/page.tsx b/src/app/(dashboard)/admin/scheduling/changes/page.tsx index 0cd5c88..523d23a 100644 --- a/src/app/(dashboard)/admin/scheduling/changes/page.tsx +++ b/src/app/(dashboard)/admin/scheduling/changes/page.tsx @@ -1,7 +1,8 @@ -import Link from "next/link" +import Link from "next/link" import { PlusCircle, ClipboardList } from "lucide-react" import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -18,9 +19,12 @@ import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule- import { ScheduleGridView } from "@/modules/scheduling/components/schedule-grid-view" import type { ScheduleChangeStatus } from "@/modules/scheduling/types" -export const metadata: Metadata = { - title: "课表变更申请 - Next_Edu", - description: "审核、批准或拒绝课表变更与代课申请", +export async function generateMetadata(): Promise { + const t = await getTranslations("scheduling") + return { + title: `${t("changes.title")} - Next_Edu`, + description: t("changes.description"), + } } export const dynamic = "force-dynamic" @@ -33,6 +37,7 @@ export default async function AdminSchedulingChangesPage({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("scheduling") await requirePermission(Permissions.SCHEDULE_ADJUST) const sp = await searchParams const statusParam = getSearchParam(sp, "status") @@ -51,15 +56,15 @@ export default async function AdminSchedulingChangesPage({
-

课表变更申请

+

{t("changes.title")}

- 审核、批准或拒绝课表变更与代课申请。 + {t("changes.description")}

@@ -67,10 +72,10 @@ export default async function AdminSchedulingChangesPage({ {items.length === 0 && !status && !classId ? ( @@ -79,15 +84,15 @@ export default async function AdminSchedulingChangesPage({ )}
-

冲突检测

+

{t("changes.conflictDetection")}

- 检测现有班级课表中的时间重叠。 + {t("changes.conflictDetectionDescription")}

{classOptions.length === 0 ? ( ) : ( @@ -95,9 +100,9 @@ export default async function AdminSchedulingChangesPage({
-

课表网格

+

{t("changes.scheduleGrid")}

- 按班级查看当前课表分布。 + {t("changes.scheduleGridDescription")}

diff --git a/src/app/(dashboard)/admin/scheduling/rules/page.tsx b/src/app/(dashboard)/admin/scheduling/rules/page.tsx index 70c3053..3806d83 100644 --- a/src/app/(dashboard)/admin/scheduling/rules/page.tsx +++ b/src/app/(dashboard)/admin/scheduling/rules/page.tsx @@ -1,6 +1,7 @@ -import { CalendarCog, ClipboardList } from "lucide-react" +import { CalendarCog, ClipboardList } from "lucide-react" import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -11,14 +12,18 @@ import { } from "@/modules/scheduling/data-access" import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form" -export const metadata: Metadata = { - title: "排课规则 - Next_Edu", - description: "配置每日课时上限、课间窗口与均衡偏好", +export async function generateMetadata(): Promise { + const t = await getTranslations("scheduling") + return { + title: `${t("rules.title")} - Next_Edu`, + description: t("rules.description"), + } } export const dynamic = "force-dynamic" export default async function AdminSchedulingRulesPage(): Promise { + const t = await getTranslations("scheduling") await requirePermission(Permissions.SCHEDULE_ADJUST) const [classes, existingRules] = await Promise.all([ getAdminClassesForScheduling(), @@ -30,17 +35,15 @@ export default async function AdminSchedulingRulesPage(): Promise { return (
-

排课规则

-

- 配置每日课时上限、课间窗口与均衡偏好。 -

+

{t("rules.title")}

+

{t("rules.description")}

{classOptions.length === 0 ? ( ) : ( @@ -48,7 +51,7 @@ export default async function AdminSchedulingRulesPage(): Promise {
- 提示:未选择具体班级时保存的规则将作为全局默认。 + {t("rules.hint")}
) diff --git a/src/app/(dashboard)/admin/school/grades/insights/page.tsx b/src/app/(dashboard)/admin/school/grades/insights/page.tsx index 0e7d3c1..9f67c14 100644 --- a/src/app/(dashboard)/admin/school/grades/insights/page.tsx +++ b/src/app/(dashboard)/admin/school/grades/insights/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import type { Metadata } from "next" import type { JSX } from "react" import { BarChart3 } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -9,6 +10,7 @@ import { getGrades } from "@/modules/school/data-access" import { getGradeHomeworkInsights } from "@/modules/classes/data-access" import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics" import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card" +import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters" import { EmptyState } from "@/shared/components/ui/empty-state" import { StatCard } from "@/shared/components/ui/stat-card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -18,19 +20,23 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { formatDate, formatNumber } from "@/shared/lib/utils" import { getParam, type SearchParams } from "@/shared/lib/search-params" -export const metadata: Metadata = { - title: "年级作业洞察 - Next_Edu", - description: "按年级聚合的作业统计与班级排名", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("grades.gradeInsights.title")} - Next_Edu`, + description: t("grades.gradeInsights.description"), + } +} + export default async function AdminGradeInsightsPage({ searchParams, }: { searchParams: Promise }): Promise { const ctx = await requirePermission(Permissions.SCHOOL_MANAGE) + const t = await getTranslations("school") const params = await searchParams const gradeId = getParam(params, "gradeId") const selected = gradeId && gradeId !== "all" ? gradeId : "" @@ -43,15 +49,22 @@ export default async function AdminGradeInsightsPage({ getSchoolWideGradeSummary(ctx.dataScope), ]) + const buildHref = (gId: string): string => { + const p = new URLSearchParams() + if (gId && gId !== "all") p.set("gradeId", gId) + const qs = p.toString() + return qs ? `/admin/school/grades/insights?${qs}` : "/admin/school/grades/insights" + } + return (
-

年级作业洞察

-

按年级聚合的作业统计与班级排名。

+

{t("grades.gradeInsights.title")}

+

{t("grades.gradeInsights.description")}

@@ -60,86 +73,57 @@ export default async function AdminGradeInsightsPage({ )} - - - 筛选 - - {grades.length} - - - -
- - - -
-
-
+ {/* 年级筛选:ChipNav 即时切换,无整页刷新 */} + ({ id: g.id, name: g.name, schoolName: g.school.name }))} + currentGradeId={selected || "all"} + buildHref={buildHref} + /> {!selected ? ( ) : !insights ? ( ) : insights.assignments.length === 0 ? ( ) : (
- 最新作业 + {t("grades.gradeInsights.homeworkTimeline")} {insights.assignments.length}
- - - - 作业 - 状态 - 创建时间 - 目标数 - 提交数 - 已批改 - 均分 - 中位数 - - - - {insights.assignments.map((a) => ( - - {a.title} - - - {a.status} - - - {formatDate(a.createdAt)} - {a.targetCount} - {a.submittedCount} - {a.gradedCount} - {formatNumber(a.scoreStats.avg)} - {formatNumber(a.scoreStats.median)} + {/* v4-P1-10: 移动端表格水平滚动 */} +
+
+ + + {t("grades.gradeInsights.assignment")} + {t("grades.gradeInsights.status")} + {t("grades.gradeInsights.created")} + {t("grades.gradeInsights.targeted")} + {t("grades.gradeInsights.submitted")} + {t("grades.gradeInsights.graded")} + {t("grades.gradeInsights.avg")} + {t("grades.gradeInsights.median")} - ))} - -
+ + + {insights.assignments.map((a) => ( + + {a.title} + + + {a.status} + + + {formatDate(a.createdAt)} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {formatNumber(a.scoreStats.avg)} + {formatNumber(a.scoreStats.median)} + + ))} + + +
- 班级排名 + {t("grades.gradeInsights.classRanking")} {insights.classes.length}
- - - - 班级 - 学生数 - 最新均分 - 上次均分 - Δ - 总体均分 - - - - {insights.classes.map((c) => ( - - - {c.class.name} - {c.class.homeroom ? ( - • {c.class.homeroom} - ) : null} - - {c.studentCounts.total} - {formatNumber(c.latestAvg)} - {formatNumber(c.prevAvg)} - {formatNumber(c.deltaAvg)} - {formatNumber(c.overallScores.avg)} + {/* v4-P1-10: 移动端表格水平滚动 */} +
+
+ + + {t("grades.gradeInsights.class")} + {t("grades.gradeInsights.students")} + {t("grades.gradeInsights.latestAvgCol")} + {t("grades.gradeInsights.prevAvg")} + {t("grades.gradeInsights.delta")} + {t("grades.gradeInsights.overallAvgCol")} - ))} - -
+ + + {insights.classes.map((c) => ( + + + {c.class.name} + {c.class.homeroom ? ( + • {c.class.homeroom} + ) : null} + + {c.studentCounts.total} + {formatNumber(c.latestAvg)} + {formatNumber(c.prevAvg)} + {formatNumber(c.deltaAvg)} + {formatNumber(c.overallScores.avg)} + + ))} + + +
diff --git a/src/app/(dashboard)/admin/school/grades/page.tsx b/src/app/(dashboard)/admin/school/grades/page.tsx index d8e31f7..fa5ecd2 100644 --- a/src/app/(dashboard)/admin/school/grades/page.tsx +++ b/src/app/(dashboard)/admin/school/grades/page.tsx @@ -6,7 +6,7 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { GradesClient } from "@/modules/school/components/grades-view" import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary" -import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access" +import { getGrades, getSchools, getStaffOptions, getGradeOverviewStats } from "@/modules/school/data-access" export const dynamic = "force-dynamic" @@ -21,7 +21,12 @@ export async function generateMetadata(): Promise { export default async function AdminGradesPage(): Promise { await requirePermission(Permissions.SCHOOL_MANAGE) const t = await getTranslations("school") - const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()]) + const [grades, schools, staff, gradeStats] = await Promise.all([ + getGrades(), + getSchools(), + getStaffOptions(), + getGradeOverviewStats(), + ]) return (
@@ -30,7 +35,7 @@ export default async function AdminGradesPage(): Promise {

{t("grades.description")}

- +
) diff --git a/src/app/(dashboard)/admin/settings/page.tsx b/src/app/(dashboard)/admin/settings/page.tsx index 1654a49..7b0249a 100644 --- a/src/app/(dashboard)/admin/settings/page.tsx +++ b/src/app/(dashboard)/admin/settings/page.tsx @@ -1,13 +1,17 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view" -export const metadata: Metadata = { - title: "系统设置 - Next_Edu", - description: "管理系统基础信息与运行参数", +export async function generateMetadata(): Promise { + const t = await getTranslations("settings") + return { + title: `${t("admin.title")} - Next_Edu`, + description: t("admin.description"), + } } export const dynamic = "force-dynamic" diff --git a/src/app/(dashboard)/admin/users/import/page.tsx b/src/app/(dashboard)/admin/users/import/page.tsx index 8628164..db366f5 100644 --- a/src/app/(dashboard)/admin/users/import/page.tsx +++ b/src/app/(dashboard)/admin/users/import/page.tsx @@ -1,7 +1,8 @@ -import { Metadata } from "next" +import { Metadata } from "next" import type { JSX } from "react" import Link from "next/link" import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -17,14 +18,18 @@ import { } from "@/shared/components/ui/table" import { UserImportDialog } from "@/modules/users/components/user-import-dialog" -export const metadata: Metadata = { - title: "批量导入用户 - Next_Edu", - description: "通过 Excel 批量导入用户", +export async function generateMetadata(): Promise { + const t = await getTranslations("users") + return { + title: `${t("import.title")} - Next_Edu`, + description: t("import.description"), + } } export const dynamic = "force-dynamic" export default async function UserImportPage(): Promise { + const t = await getTranslations("users") await requirePermission(Permissions.USER_MANAGE) return (
@@ -34,13 +39,13 @@ export default async function UserImportPage(): Promise { -

批量导入用户

+

{t("import.title")}

- 通过 Excel 文件批量创建用户账号,支持学生自动加入班级。 + {t("import.subtitle")}

@@ -51,26 +56,26 @@ export default async function UserImportPage(): Promise {
- 导入说明 + {t("import.instructionsTitle")}
- 使用 Excel 批量导入用户的步骤 + {t("import.instructionsDescription")}
1 -

点击「批量导入用户」按钮,下载导入模板。

+

{t("import.step1")}

2 -

按模板格式填写用户信息(姓名、邮箱、角色、手机、班级邀请码)。

+

{t("import.step2")}

3 -

上传填写好的 Excel 文件,系统将解析并预览数据。

+

{t("import.step3")}

4 -

确认预览数据无误后,点击「确认导入」完成批量创建。

+

{t("import.step4")}

@@ -79,17 +84,17 @@ export default async function UserImportPage(): Promise {
- 注意事项 + {t("import.notesTitle")}
- 导入前请仔细阅读 + {t("import.notesDescription")}
-

• 默认密码为 123456,请提示用户首次登录后修改。

-

• 邮箱必须唯一,重复邮箱将被跳过并记录在错误报告中。

-

• 角色可选:admin / teacher / student / parent / grade_head / teaching_head。

-

• 班级邀请码仅对 student 角色有效,填写后学生将自动加入对应班级。

-

• 单次最多导入 10MB 的文件,建议单次不超过 500 条记录。

-

• 导入完成后将显示成功数、失败数及详细错误信息。

+

{t("import.note1")}

+

{t("import.note2")}

+

{t("import.note3")}

+

{t("import.note4")}

+

{t("import.note5")}

+

{t("import.note6")}

@@ -98,45 +103,45 @@ export default async function UserImportPage(): Promise {
- 模板字段说明 + {t("import.templateTitle")}
- Excel 模板各列含义与要求 + {t("import.templateDescription")}
- 列名 - 是否必填 - 说明 + {t("import.columnName")} + {t("import.columnRequired")} + {t("import.columnDescription")} - 姓名 - 必填 - 用户姓名 + {t("import.fieldName")} + {t("import.fieldNameRequired")} + {t("import.fieldNameDescription")} - 邮箱 - 必填 - 登录账号,需符合邮箱格式且唯一 + {t("import.fieldEmail")} + {t("import.fieldEmailRequired")} + {t("import.fieldEmailDescription")} - 角色 - 必填 - admin / teacher / student / parent / grade_head / teaching_head + {t("import.fieldRole")} + {t("import.fieldRoleRequired")} + {t("import.fieldRoleDescription")} - 手机 - 选填 - 联系电话 + {t("import.fieldPhone")} + {t("import.fieldPhoneRequired")} + {t("import.fieldPhoneDescription")} - 班级邀请码 - 选填 - 仅 student 角色有效,6 位邀请码 + {t("import.fieldInviteCode")} + {t("import.fieldInviteCodeRequired")} + {t("import.fieldInviteCodeDescription")}
diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx index 0b9ce62..a6a1fc1 100644 --- a/src/app/(dashboard)/admin/users/page.tsx +++ b/src/app/(dashboard)/admin/users/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -7,9 +8,12 @@ import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import { getAdminUsers, getAdminUserRoles } from "@/modules/users/data-access" import { AdminUsersView } from "@/modules/users/components/admin-users-view" -export const metadata: Metadata = { - title: "用户管理 - Next_Edu", - description: "管理系统所有用户", +export async function generateMetadata(): Promise { + const t = await getTranslations("users") + return { + title: `${t("title")} - Next_Edu`, + description: t("description"), + } } export const dynamic = "force-dynamic" diff --git a/src/app/(dashboard)/management/grade/dashboard/loading.tsx b/src/app/(dashboard)/management/grade/dashboard/loading.tsx new file mode 100644 index 0000000..4da44cf --- /dev/null +++ b/src/app/(dashboard)/management/grade/dashboard/loading.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ManagementGradeDashboardLoading() { + return ( +
+
+ + +
+ + + + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/dashboard/page.tsx b/src/app/(dashboard)/management/grade/dashboard/page.tsx new file mode 100644 index 0000000..a78439a --- /dev/null +++ b/src/app/(dashboard)/management/grade/dashboard/page.tsx @@ -0,0 +1,178 @@ +import type { Metadata } from "next" +import type { JSX } from "react" + +import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getTeacherIdForMutations } from "@/modules/classes/data-access" +import { getGradeHomeworkInsights } from "@/modules/classes/data-access" +import { getGradesForStaff } from "@/modules/school/data-access" +import { getGradeDistributionByGradeId } from "@/modules/grades/data-access-analytics" +import { getExamsByGradeId } from "@/modules/exams/data-access" +import { getGradeCoursePlanProgress } from "@/modules/course-plans/data-access" +import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters" +import { GradeDistributionPanel } from "@/modules/school/components/grade-dashboard/grade-distribution-panel" +import { GradeHomeworkPanel } from "@/modules/school/components/grade-dashboard/grade-homework-panel" +import { GradeExamsPanel } from "@/modules/school/components/grade-dashboard/grade-exams-panel" +import { GradeProgressPanel } from "@/modules/school/components/grade-dashboard/grade-progress-panel" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { ChipNav } from "@/shared/components/ui/chip-nav" +import { BarChart3 } from "lucide-react" +import { getParam, type SearchParams } from "@/shared/lib/search-params" + +export const dynamic = "force-dynamic" + +const TAB_OPTIONS = [ + { id: "distribution", name: "" }, + { id: "homework", name: "" }, + { id: "exams", name: "" }, + { id: "progress", name: "" }, +] as const + +type TabId = (typeof TAB_OPTIONS)[number]["id"] + +const isTabId = (v: string): v is TabId => + v === "distribution" || v === "homework" || v === "exams" || v === "progress" + +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("grades.gradeDashboard.title")} - Next_Edu`, + description: t("grades.gradeDashboard.description"), + } +} + +export default async function GradeDashboardPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("school") + const params = await searchParams + const gradeId = getParam(params, "gradeId") + const tabRaw = getParam(params, "tab") || "distribution" + const tab: TabId = isTabId(tabRaw) ? tabRaw : "distribution" + + const teacherId = await getTeacherIdForMutations() + const grades = await getGradesForStaff(teacherId) + const allowedIds = new Set(grades.map((g) => g.id)) + const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : "" + + const buildHref = (gId: string): string => { + const p = new URLSearchParams() + if (gId && gId !== "all") p.set("gradeId", gId) + if (tab !== "distribution") p.set("tab", tab) + const qs = p.toString() + return qs ? `/management/grade/dashboard?${qs}` : "/management/grade/dashboard" + } + + const buildTabHref = (tId: string): string => { + const p = new URLSearchParams() + if (selected) p.set("gradeId", selected) + if (tId !== "distribution") p.set("tab", tId) + const qs = p.toString() + return qs ? `/management/grade/dashboard?${qs}` : "/management/grade/dashboard" + } + + const tabOptions = TAB_OPTIONS.map((o) => ({ + id: o.id, + name: t(`grades.gradeDashboard.tabs.${o.id}` as const), + })) + + if (grades.length === 0) { + return ( +
+
+

{t("grades.gradeDashboard.title")}

+

{t("grades.gradeDashboard.description")}

+
+ +
+ ) + } + + // Fetch data for the active tab only + let distributionData = null + let homeworkData = null + let examsData = null + let progressData = null + + if (selected) { + if (tab === "distribution") { + distributionData = await getGradeDistributionByGradeId({ + gradeId: selected, + scope: ctx.dataScope, + }) + } else if (tab === "homework") { + homeworkData = await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) + } else if (tab === "exams") { + examsData = await getExamsByGradeId({ gradeId: selected, scope: ctx.dataScope }) + } else if (tab === "progress") { + progressData = await getGradeCoursePlanProgress({ gradeId: selected }) + } + } + + return ( +
+
+

{t("grades.gradeDashboard.title")}

+

{t("grades.gradeDashboard.description")}

+
+ + ({ id: g.id, name: g.name, schoolName: g.school.name }))} + currentGradeId={selected || "all"} + buildHref={buildHref} + /> + + {!selected ? ( + + ) : ( +
+ + + {tab === "distribution" && distributionData && ( + + )} + {tab === "homework" && homeworkData && ( + + )} + {tab === "exams" && examsData && ( + + )} + {tab === "progress" && progressData && ( + + )} + + {/* Fallback: data was null (e.g. homework insights returned null) */} + {((tab === "distribution" && !distributionData) || + (tab === "homework" && !homeworkData) || + (tab === "exams" && !examsData) || + (tab === "progress" && !progressData)) && ( + + )} +
+ )} +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/insights/page.tsx b/src/app/(dashboard)/management/grade/insights/page.tsx index ecf4c68..fc536d6 100644 --- a/src/app/(dashboard)/management/grade/insights/page.tsx +++ b/src/app/(dashboard)/management/grade/insights/page.tsx @@ -7,25 +7,23 @@ import { Permissions } from "@/shared/types/permissions" import { getTeacherIdForMutations } from "@/modules/classes/data-access" import { getGradeHomeworkInsights } from "@/modules/classes/data-access" import { getGradesForStaff } from "@/modules/school/data-access" +import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters" import { EmptyState } from "@/shared/components/ui/empty-state" import { StatCard } from "@/shared/components/ui/stat-card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" -import { Button } from "@/shared/components/ui/button" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { BarChart3 } from "lucide-react" -import { formatDate } from "@/shared/lib/utils" +import { formatDate, formatNumber } from "@/shared/lib/utils" import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" -const formatScore = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-") - export async function generateMetadata(): Promise { const t = await getTranslations("school") return { - title: `${t("classManagement.grade.insights.title")} - Next_Edu`, - description: t("classManagement.grade.insights.description"), + title: `${t("grades.gradeInsights.title")} - Next_Edu`, + description: t("grades.gradeInsights.description"), } } @@ -42,17 +40,24 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null + const buildHref = (gId: string): string => { + const p = new URLSearchParams() + if (gId && gId !== "all") p.set("gradeId", gId) + const qs = p.toString() + return qs ? `/management/grade/insights?${qs}` : "/management/grade/insights" + } + if (grades.length === 0) { return (
-

{t("classManagement.grade.insights.title")}

-

{t("classManagement.grade.insights.description")}

+

{t("grades.gradeInsights.title")}

+

{t("grades.gradeInsights.description")}

@@ -62,85 +67,62 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc return (
-

{t("classManagement.grade.insights.title")}

-

{t("classManagement.grade.insights.description")}

+

{t("grades.gradeInsights.title")}

+

{t("grades.gradeInsights.description")}

- - - {t("classManagement.grade.insights.filters")} - - {grades.length} - - - -
- - - -
-
-
+ {/* 年级筛选:ChipNav 即时切换,无整页刷新 */} + ({ id: g.id, name: g.name, schoolName: g.school.name }))} + currentGradeId={selected || "all"} + buildHref={buildHref} + /> {!selected ? ( ) : !insights ? ( ) : insights.assignments.length === 0 ? ( ) : (
@@ -148,85 +130,91 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc - {t("classManagement.grade.insights.homeworkTimeline")} + {t("grades.gradeInsights.homeworkTimeline")} {insights.assignments.length}
- - - - {t("classManagement.grade.insights.assignment")} - {t("classManagement.grade.insights.status")} - {t("classManagement.grade.insights.created")} - {t("classManagement.grade.insights.targeted")} - {t("classManagement.grade.insights.submitted")} - {t("classManagement.grade.insights.graded")} - {t("classManagement.grade.insights.avg")} - {t("classManagement.grade.insights.median")} - - - - {insights.assignments.map((a) => ( - - {a.title} - - - {a.status} - - - {formatDate(a.createdAt)} - {a.targetCount} - {a.submittedCount} - {a.gradedCount} - {formatScore(a.scoreStats.avg)} - {formatScore(a.scoreStats.median)} + {/* v4-P1-11: 移动端表格水平滚动 */} +
+
+ + + {t("grades.gradeInsights.assignment")} + {t("grades.gradeInsights.status")} + {t("grades.gradeInsights.created")} + {t("grades.gradeInsights.targeted")} + {t("grades.gradeInsights.submitted")} + {t("grades.gradeInsights.graded")} + {t("grades.gradeInsights.avg")} + {t("grades.gradeInsights.median")} - ))} - -
+ + + {insights.assignments.map((a) => ( + + {a.title} + + + {a.status} + + + {formatDate(a.createdAt)} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {formatNumber(a.scoreStats.avg)} + {formatNumber(a.scoreStats.median)} + + ))} + + +
- {t("classManagement.grade.insights.classRanking")} + {t("grades.gradeInsights.classRanking")} {insights.classes.length}
- - - - {t("classManagement.grade.insights.class")} - {t("classManagement.grade.insights.students")} - {t("classManagement.grade.insights.latestAvgCol")} - {t("classManagement.grade.insights.prevAvg")} - {t("classManagement.grade.insights.delta")} - {t("classManagement.grade.insights.overallAvgCol")} - - - - {insights.classes.map((c) => ( - - - {c.class.name} - {c.class.homeroom ? • {c.class.homeroom} : null} - - {c.studentCounts.total} - {formatScore(c.latestAvg)} - {formatScore(c.prevAvg)} - {formatScore(c.deltaAvg)} - {formatScore(c.overallScores.avg)} + {/* v4-P1-11: 移动端表格水平滚动 */} +
+
+ + + {t("grades.gradeInsights.class")} + {t("grades.gradeInsights.students")} + {t("grades.gradeInsights.latestAvgCol")} + {t("grades.gradeInsights.prevAvg")} + {t("grades.gradeInsights.delta")} + {t("grades.gradeInsights.overallAvgCol")} - ))} - -
+ + + {insights.classes.map((c) => ( + + + {c.class.name} + {c.class.homeroom ? • {c.class.homeroom} : null} + + {c.studentCounts.total} + {formatNumber(c.latestAvg)} + {formatNumber(c.prevAvg)} + {formatNumber(c.deltaAvg)} + {formatNumber(c.overallScores.avg)} + + ))} + + +
diff --git a/src/app/(dashboard)/management/grade/practice/error.tsx b/src/app/(dashboard)/management/grade/practice/error.tsx new file mode 100644 index 0000000..0d3339f --- /dev/null +++ b/src/app/(dashboard)/management/grade/practice/error.tsx @@ -0,0 +1,27 @@ +"use client" + +import { useEffect } from "react" +import { BarChart3 } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function Error() { + useEffect(() => { + console.error("Grade practice analytics page error") + }, []) + + return ( +
+
+

年级专项练习总览

+

加载年级练习数据时发生错误

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/practice/loading.tsx b/src/app/(dashboard)/management/grade/practice/loading.tsx new file mode 100644 index 0000000..ea9f978 --- /dev/null +++ b/src/app/(dashboard)/management/grade/practice/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/practice/page.tsx b/src/app/(dashboard)/management/grade/practice/page.tsx new file mode 100644 index 0000000..2c3fc19 --- /dev/null +++ b/src/app/(dashboard)/management/grade/practice/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { BarChart3 } from "lucide-react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { getTeacherIdForMutations } from "@/modules/classes/data-access" +import { getGradesForStaff } from "@/modules/school/data-access" +import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters" + +import { + getGradePracticeOverview, + getGradeClassPracticeComparison, + getPracticeTypeBreakdown, +} from "@/modules/adaptive-practice/data-access-analytics" +import { getUserIdsByGradeId } from "@/modules/users/data-access" +import { PracticeOverviewStatsCards } from "@/modules/adaptive-practice/components/practice-overview-stats-cards" +import { ClassPracticeComparisonTable } from "@/modules/adaptive-practice/components/class-practice-comparison-table" +import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/components/practice-type-breakdown-chart" + +export const dynamic = "force-dynamic" + +export async function generateMetadata(): Promise { + const t = await getTranslations("practice") + return { + title: `${t("grade.title")} - Next_Edu`, + description: t("grade.description"), + } +} + +export default async function GradePracticePage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + const t = await getTranslations("practice") + const params = await searchParams + const gradeId = getParam(params, "gradeId") + + const teacherId = await getTeacherIdForMutations() + const grades = await getGradesForStaff(teacherId) + const allowedIds = new Set(grades.map((g) => g.id)) + const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : "" + + const buildHref = (gId: string): string => { + const p = new URLSearchParams() + if (gId && gId !== "all") p.set("gradeId", gId) + const qs = p.toString() + return qs ? `/management/grade/practice?${qs}` : "/management/grade/practice" + } + + if (grades.length === 0) { + return ( +
+
+

{t("grade.title")}

+

{t("grade.description")}

+
+ +
+ ) + } + + // 仅在选中年级时查询数据 + let overview: Awaited> = null + let classComparison: Awaited> = [] + let typeBreakdown: Awaited> = [] + + if (selected) { + const studentIds = await getUserIdsByGradeId(selected) + const [ov, cmp, breakdown] = await Promise.all([ + getGradePracticeOverview(selected), + getGradeClassPracticeComparison(selected), + getPracticeTypeBreakdown(studentIds), + ]) + overview = ov + classComparison = cmp + typeBreakdown = breakdown + } + + return ( +
+
+

{t("grade.title")}

+

{t("grade.description")}

+
+ + ({ id: g.id, name: g.name, schoolName: g.school.name }))} + currentGradeId={selected || "all"} + buildHref={buildHref} + /> + + {!selected ? ( + + ) : !overview ? ( + + ) : ( +
+ {/* 年级整体统计卡片 */} + + + {/* 各班级练习对比表 */} + {classComparison.length > 0 ? ( + + ) : null} + + {/* 练习类型分布图 */} + {typeBreakdown.length > 0 ? ( + + ) : null} +
+ )} +
+ ) +} diff --git a/src/app/(dashboard)/parent/children/[studentId]/page.tsx b/src/app/(dashboard)/parent/children/[studentId]/page.tsx index 95a3381..ce25626 100644 --- a/src/app/(dashboard)/parent/children/[studentId]/page.tsx +++ b/src/app/(dashboard)/parent/children/[studentId]/page.tsx @@ -14,6 +14,7 @@ import { } from "@/modules/parent/components/child-detail-panel" import { EmptyState } from "@/shared/components/ui/empty-state" import { ShieldAlert } from "lucide-react" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" @@ -27,6 +28,7 @@ export default async function ChildDetailPage({ const { studentId } = await params const sp = await searchParams const ctx = await requireAuth() + const t = await getTranslations("common") // 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露) const relation = await verifyParentChildRelation(studentId, ctx.userId) @@ -41,8 +43,8 @@ export default async function ChildDetailPage({
diff --git a/src/app/(dashboard)/parent/diagnostic/page.tsx b/src/app/(dashboard)/parent/diagnostic/page.tsx index eadb3ff..db15bfe 100644 --- a/src/app/(dashboard)/parent/diagnostic/page.tsx +++ b/src/app/(dashboard)/parent/diagnostic/page.tsx @@ -11,6 +11,7 @@ import { } from "@/modules/parent/components/parent-children-data-page" import { getUserNamesByIds } from "@/modules/users/data-access" import { Card, CardContent } from "@/shared/components/ui/card" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" @@ -34,15 +35,16 @@ type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem export default async function ParentDiagnosticPage() { const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) + const t = await getTranslations("diagnostic") if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { return ( ) } @@ -68,7 +70,7 @@ export default async function ParentDiagnosticPage() { const items: ChildDiagnosticItem[] = results.map((r, idx) => { const studentId = childrenIds[idx] - const studentName = nameMap.get(studentId)?.name ?? "Unknown student" + const studentName = nameMap.get(studentId)?.name ?? t("parent.selectChild") if (r.status === "fulfilled") { return { studentId, @@ -88,11 +90,11 @@ export default async function ParentDiagnosticPage() { return ( ( <> @@ -111,10 +113,10 @@ export default async function ParentDiagnosticPage() { diff --git a/src/app/(dashboard)/parent/error-book/page.tsx b/src/app/(dashboard)/parent/error-book/page.tsx index 8b4a3b0..9cbeda1 100644 --- a/src/app/(dashboard)/parent/error-book/page.tsx +++ b/src/app/(dashboard)/parent/error-book/page.tsx @@ -17,23 +17,25 @@ import { } from "@/modules/error-book/data-access" import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards" import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" export default async function ParentErrorBookPage(): Promise { const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + const t = await getTranslations("errorBook") if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { return (
-

子女错题本

-

查看子女的错题情况与学习进度。

+

{t("parent.title")}

+

{t("parent.description")}

@@ -56,8 +58,8 @@ export default async function ParentErrorBookPage(): Promise { return (
-

子女错题本

-

查看子女的错题情况与学习进度。

+

{t("parent.title")}

+

{t("parent.description")}

{childrenIds.length === 1 ? ( @@ -68,35 +70,35 @@ export default async function ParentErrorBookPage(): Promise {
{childrenIds.map((childId, idx) => { const stats = childStatsList[idx] - const name = nameMap.get(childId) ?? "未知" + const name = nameMap.get(childId) ?? t("parent.unknown") return ( {name} - {formatNumber(stats.masteredRate * 100, 0)}% 掌握 + {t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })}
-
错题总数
+
{t("parent.totalErrors")}
{stats.totalCount}
-
待复习
+
{t("parent.dueReview")}
{stats.dueReviewCount}
-
待学习
+
{t("parent.newItems")}
{stats.newCount}
-
已掌握
+
{t("parent.mastered")}
{stats.masteredCount}
@@ -112,7 +114,7 @@ export default async function ParentErrorBookPage(): Promise { {weakKps.length > 0 ? ( - 薄弱知识点 + {t("parent.weakPoints")}
@@ -121,7 +123,7 @@ export default async function ParentErrorBookPage(): Promise {
{kp.knowledgePointName} - {kp.errorCount} 错 · {formatNumber(kp.masteryRate * 100, 0)}% 掌握 + {t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })}
diff --git a/src/app/(dashboard)/parent/error.tsx b/src/app/(dashboard)/parent/error.tsx index 539423a..9b65b31 100644 --- a/src/app/(dashboard)/parent/error.tsx +++ b/src/app/(dashboard)/parent/error.tsx @@ -2,6 +2,7 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import { AlertTriangle } from "lucide-react" +import { useTranslations } from "next-intl" export default function ParentError({ error, @@ -10,13 +11,15 @@ export default function ParentError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("common") + return (
) diff --git a/src/app/(dashboard)/parent/grades/page.tsx b/src/app/(dashboard)/parent/grades/page.tsx index ed7c9df..a11e5e9 100644 --- a/src/app/(dashboard)/parent/grades/page.tsx +++ b/src/app/(dashboard)/parent/grades/page.tsx @@ -11,6 +11,7 @@ import { import { ParentExportButton } from "@/modules/parent/components/parent-export-button" import { GraduationCap } from "lucide-react" import type { ClassAverageTrendResult } from "@/modules/grades/types" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" @@ -22,15 +23,16 @@ interface ChildGradeItem { export default async function ParentGradesPage() { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { return ( ) } @@ -64,11 +66,11 @@ export default async function ParentGradesPage() { return ( ( <> diff --git a/src/app/(dashboard)/parent/leave/page.tsx b/src/app/(dashboard)/parent/leave/page.tsx index c80c13c..ca15932 100644 --- a/src/app/(dashboard)/parent/leave/page.tsx +++ b/src/app/(dashboard)/parent/leave/page.tsx @@ -4,23 +4,26 @@ import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react" 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 { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" export default async function ParentLeavePage() { + const t = await getTranslations("leave") + return (
-

Leave Request

+

{t("title")}

- Submit a leave request for your child. + {t("description")}

@@ -28,33 +31,33 @@ export default async function ParentLeavePage() { - Online Leave Request + {t("onlineLeave")}
-
Contact options
+
{t("contactOptions")}
  • - Call the school office during working hours + {t("callOffice")}
  • - Send a message to the homeroom teacher via the Messages page + {t("sendMessage")}
  • - Go to Messages + {t("goToMessages")}
diff --git a/src/app/(dashboard)/parent/lesson-plans/[planId]/view/error.tsx b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/error.tsx new file mode 100644 index 0000000..37caabb --- /dev/null +++ b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentLessonPlanViewError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/lesson-plans/[planId]/view/loading.tsx b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/loading.tsx new file mode 100644 index 0000000..07a8548 --- /dev/null +++ b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ParentLessonPlanViewLoading() { + return ( +
+
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx new file mode 100644 index 0000000..d8d5d75 --- /dev/null +++ b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx @@ -0,0 +1,81 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { getTranslations } from "next-intl/server" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export const dynamic = "force-dynamic" + +export default async function ParentLessonPlanViewPage({ + params, +}: { + params: Promise<{ planId: string }> +}): Promise { + const { planId } = await params + const t = await getTranslations("lessonPreparation") + const ctx = await getAuthContext() + + const plan = await getLessonPlanById(planId, ctx.userId) + if (!plan) { + return ( +
+
+ {t("readonly.notFound")} +
+
+ ) + } + + if (plan.status !== "published") { + return ( +
+
+ {t("readonly.notPublished")} +
+
+ ) + } + + let textbookTitle: string | undefined + let chapterTitle: string | undefined + if (plan.textbookId) { + const textbook = await getTextbookById(plan.textbookId) + textbookTitle = textbook?.title + if (plan.chapterId) { + const chapters = await getChaptersByTextbookId(plan.textbookId) + const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { + for (const ch of list) { + if (ch.id === plan.chapterId) return ch + if (ch.children && ch.children.length > 0) { + const found = findChapter(ch.children as typeof chapters) + if (found) return found + } + } + return undefined + } + const chapter = findChapter(chapters) + chapterTitle = chapter?.title + } + } + + return ( +
+ + +
+ } + > + + +
+ ) +} diff --git a/src/app/(dashboard)/parent/lesson-plans/error.tsx b/src/app/(dashboard)/parent/lesson-plans/error.tsx new file mode 100644 index 0000000..25a0082 --- /dev/null +++ b/src/app/(dashboard)/parent/lesson-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentLessonPlansError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/lesson-plans/loading.tsx b/src/app/(dashboard)/parent/lesson-plans/loading.tsx new file mode 100644 index 0000000..c071cec --- /dev/null +++ b/src/app/(dashboard)/parent/lesson-plans/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ParentLessonPlansLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/lesson-plans/page.tsx b/src/app/(dashboard)/parent/lesson-plans/page.tsx new file mode 100644 index 0000000..d238aa3 --- /dev/null +++ b/src/app/(dashboard)/parent/lesson-plans/page.tsx @@ -0,0 +1,44 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { getTranslations } from "next-intl/server" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getLessonPlans } from "@/modules/lesson-preparation/data-access" +import { getSubjectOptions } from "@/modules/school/data-access" +import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export const dynamic = "force-dynamic" + +export default async function ParentLessonPlansPage(): Promise { + const t = await getTranslations("lessonPreparation") + const ctx = await getAuthContext() + + const [items, subjects] = await Promise.all([ + getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId), + getSubjectOptions(), + ]) + + return ( +
+
+

{t("parent.title")}

+

{t("parent.description")}

+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ } + > + + +
+ ) +} diff --git a/src/app/(dashboard)/student/attendance/error.tsx b/src/app/(dashboard)/student/attendance/error.tsx index 5cfd446..a4b50fd 100644 --- a/src/app/(dashboard)/student/attendance/error.tsx +++ b/src/app/(dashboard)/student/attendance/error.tsx @@ -14,7 +14,7 @@ export default function StudentAttendanceError({ reset }: { error: Error & { dig title={t("errors.unexpected")} description={t("errors.unexpected")} action={{ - label: t("actions.save"), + label: t("actions.retry"), onClick: () => reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/student/diagnostic/error.tsx b/src/app/(dashboard)/student/diagnostic/error.tsx index 5bd3d0e..b14ae1e 100644 --- a/src/app/(dashboard)/student/diagnostic/error.tsx +++ b/src/app/(dashboard)/student/diagnostic/error.tsx @@ -1,6 +1,7 @@ "use client" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -10,14 +11,15 @@ export default function StudentDiagnosticError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("student") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/student/diagnostic/page.tsx b/src/app/(dashboard)/student/diagnostic/page.tsx index 6b6d4cb..6183b25 100644 --- a/src/app/(dashboard)/student/diagnostic/page.tsx +++ b/src/app/(dashboard)/student/diagnostic/page.tsx @@ -1,4 +1,6 @@ import { Stethoscope } from "lucide-react" +import { getTranslations } from "next-intl/server" + import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getStudentMasterySummary } from "@/modules/diagnostic/data-access" @@ -9,6 +11,7 @@ export const dynamic = "force-dynamic" export default async function StudentDiagnosticPage() { const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) + const t = await getTranslations("student") const [summary, reportsResult] = await Promise.all([ getStudentMasterySummary(ctx.userId), @@ -25,10 +28,10 @@ export default async function StudentDiagnosticPage() {

- My Diagnostic + {t("diagnostic.title")}

- Your knowledge point mastery analysis and diagnostic reports. + {t("diagnostic.description")}

diff --git a/src/app/(dashboard)/student/elective/error.tsx b/src/app/(dashboard)/student/elective/error.tsx index ac69f33..4f1b27d 100644 --- a/src/app/(dashboard)/student/elective/error.tsx +++ b/src/app/(dashboard)/student/elective/error.tsx @@ -14,7 +14,7 @@ export default function StudentElectiveError({ reset }: { error: Error & { diges title={t("errors.unexpected")} description={t("errors.unexpected")} action={{ - label: t("actions.save"), + label: t("actions.retry"), onClick: () => reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/student/error-book/error.tsx b/src/app/(dashboard)/student/error-book/error.tsx index 8d3e742..4d1e5a2 100644 --- a/src/app/(dashboard)/student/error-book/error.tsx +++ b/src/app/(dashboard)/student/error-book/error.tsx @@ -1,17 +1,19 @@ "use client" import { BookX } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" export default function StudentErrorBookError() { + const t = useTranslations("student") return (
window.location.reload() }} + title={t("error.title")} + description={t("error.description")} + action={{ label: t("error.retry"), onClick: () => window.location.reload() }} className="border-none shadow-none" />
diff --git a/src/app/(dashboard)/student/error-book/page.tsx b/src/app/(dashboard)/student/error-book/page.tsx index 2415d7d..660811d 100644 --- a/src/app/(dashboard)/student/error-book/page.tsx +++ b/src/app/(dashboard)/student/error-book/page.tsx @@ -1,5 +1,6 @@ import type { JSX } from "react" import { Suspense } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -92,6 +93,7 @@ export default async function StudentErrorBookPage({ searchParams: Promise }): Promise { const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + const t = await getTranslations("student") const stats = await getErrorBookStats(ctx.userId) const aiClientService = createAiClientService() @@ -100,9 +102,9 @@ export default async function StudentErrorBookPage({
-

错题本

+

{t("errorBook.title")}

- 自动收录考试与作业中的错题,科学复习,攻克薄弱点。 + {t("errorBook.description")}

diff --git a/src/app/(dashboard)/student/error.tsx b/src/app/(dashboard)/student/error.tsx index 28bcdad..285ef6b 100644 --- a/src/app/(dashboard)/student/error.tsx +++ b/src/app/(dashboard)/student/error.tsx @@ -1,7 +1,9 @@ "use client" -import { EmptyState } from "@/shared/components/ui/empty-state" import { AlertTriangle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" export default function StudentError({ error, @@ -10,12 +12,13 @@ export default function StudentError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("student") return ( ) } diff --git a/src/app/(dashboard)/student/grades/error.tsx b/src/app/(dashboard)/student/grades/error.tsx index 4f0086d..de6ea08 100644 --- a/src/app/(dashboard)/student/grades/error.tsx +++ b/src/app/(dashboard)/student/grades/error.tsx @@ -1,6 +1,7 @@ "use client" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -10,14 +11,15 @@ export default function StudentGradesError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("student") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/student/grades/page.tsx b/src/app/(dashboard)/student/grades/page.tsx index a593c0c..6051175 100644 --- a/src/app/(dashboard)/student/grades/page.tsx +++ b/src/app/(dashboard)/student/grades/page.tsx @@ -20,6 +20,7 @@ export default async function StudentGradesPage({ searchParams: Promise }) { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([ searchParams, getStudentGradeSummary(ctx.userId, ctx.dataScope), @@ -32,7 +33,6 @@ export default async function StudentGradesPage({ ]) if (!summary) { - const t = await getTranslations("grades") return (
@@ -73,7 +73,7 @@ export default async function StudentGradesPage({

{summary.studentName}

-

{summary.records.length} 条成绩记录

+

{t("summary.recordCount", { count: summary.records.length })}

({ id: s.id, name: s.name }))} /> {filteredSummary.records.length > 0 && ( diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx index 33015ac..5295d19 100644 --- a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { getStudentHomeworkTakeData } from "@/modules/homework/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" @@ -17,6 +18,8 @@ export default async function StudentAssignmentTakePage({ const student = await getCurrentStudentUser() if (!student) return notFound() + const t = await getTranslations("student") + const data = await getStudentHomeworkTakeData(assignmentId, student.id) if (!data) return notFound() @@ -28,7 +31,7 @@ export default async function StudentAssignmentTakePage({

{data.assignment.title}

- Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"} + {t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}
@@ -42,9 +45,9 @@ export default async function StudentAssignmentTakePage({

{data.assignment.title}

- Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"} + {t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })} - Max Attempts: {data.assignment.maxAttempts} + {t("assignment.maxAttempts", { count: data.assignment.maxAttempts })}
diff --git a/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx b/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx index 32aa03f..99ff44f 100644 --- a/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx +++ b/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx @@ -10,6 +10,7 @@ import { School, User, } from "lucide-react" +import { getTranslations } from "next-intl/server" import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" @@ -20,14 +21,14 @@ import { EmptyState } from "@/shared/components/ui/empty-state" export const dynamic = "force-dynamic" -const WEEKDAYS: Record = { - 1: "Mon", - 2: "Tue", - 3: "Wed", - 4: "Thu", - 5: "Fri", - 6: "Sat", - 7: "Sun", +const WEEKDAY_KEYS: Record = { + 1: "mon", + 2: "tue", + 3: "wed", + 4: "thu", + 5: "fri", + 6: "sat", + 7: "sun", } export default async function StudentClassDetailPage({ @@ -39,6 +40,8 @@ export default async function StudentClassDetailPage({ const student = await getCurrentStudentUser() if (!student) return notFound() + const t = await getTranslations("student") + const [classInfo, schedule] = await Promise.all([ getStudentClassById(student.id, classId), getStudentSchedule(student.id), @@ -58,14 +61,14 @@ export default async function StudentClassDetailPage({

{classInfo.name}

- Grade {classInfo.grade} + {t("classDetail.grade", { grade: classInfo.grade })} {classInfo.homeroom && ( <> @@ -78,24 +81,24 @@ export default async function StudentClassDetailPage({ - Room {classInfo.room} + {t("classDetail.room", { room: classInfo.room })} )} - Active + {t("classDetail.active")}
@@ -107,7 +110,7 @@ export default async function StudentClassDetailPage({ - Teacher + {t("classDetail.teacher")} @@ -117,7 +120,7 @@ export default async function StudentClassDetailPage({ {classInfo.teacherName}
) : ( -

No teacher assigned.

+

{t("classDetail.noTeacher")}

)} {classInfo.teacherEmail && (
@@ -138,7 +141,7 @@ export default async function StudentClassDetailPage({ - School + {t("classDetail.school")} @@ -148,12 +151,12 @@ export default async function StudentClassDetailPage({ {classInfo.schoolName}
) : ( -

School info not available.

+

{t("classDetail.schoolNotAvailable")}

)} {classInfo.grade && (
- Grade {classInfo.grade} + {t("classDetail.grade", { grade: classInfo.grade })}
)} @@ -164,22 +167,22 @@ export default async function StudentClassDetailPage({ - Classroom + {t("classDetail.classroom")} {classInfo.room ? (
- Room {classInfo.room} + {t("classDetail.room", { room: classInfo.room })}
) : ( -

Room not assigned.

+

{t("classDetail.roomNotAssigned")}

)} {classInfo.homeroom && (
- Homeroom: {classInfo.homeroom} + {t("classDetail.homeroom", { homeroom: classInfo.homeroom })}
)}
@@ -191,15 +194,15 @@ export default async function StudentClassDetailPage({ - Class Schedule + {t("classDetail.classSchedule")} {classSchedule.length === 0 ? ( ) : ( @@ -211,7 +214,7 @@ export default async function StudentClassDetailPage({ >
- {WEEKDAYS[s.weekday]} + {t(`weekdays.${WEEKDAY_KEYS[s.weekday]}`)}

{s.course}

diff --git a/src/app/(dashboard)/student/learning/courses/page.tsx b/src/app/(dashboard)/student/learning/courses/page.tsx index 0974d1e..daf4eab 100644 --- a/src/app/(dashboard)/student/learning/courses/page.tsx +++ b/src/app/(dashboard)/student/learning/courses/page.tsx @@ -1,4 +1,5 @@ import { UserX } from "lucide-react" +import { getTranslations } from "next-intl/server" import { getStudentClasses } from "@/modules/classes/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" @@ -14,17 +15,18 @@ export default async function StudentCoursesPage({ }: { searchParams: Promise }) { + const t = await getTranslations("student") const student = await getCurrentStudentUser() if (!student) { return (
-

Courses

-

Your enrolled classes.

+

{t("courses.title")}

+

{t("courses.description")}

@@ -51,8 +53,8 @@ export default async function StudentCoursesPage({ return (
-

Courses

-

Your enrolled classes.

+

{t("courses.title")}

+

{t("courses.description")}

{classes.length > 0 && } diff --git a/src/app/(dashboard)/student/learning/page.tsx b/src/app/(dashboard)/student/learning/page.tsx index 227c786..21b6f4d 100644 --- a/src/app/(dashboard)/student/learning/page.tsx +++ b/src/app/(dashboard)/student/learning/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link" -import { BookOpen, PenTool, Library, ArrowRight } from "lucide-react" +import { BookOpen, PenTool, Library, ArrowRight, UserX } from "lucide-react" +import { getTranslations } from "next-intl/server" import { getStudentClasses } from "@/modules/classes/data-access" import { getStudentHomeworkAssignments } from "@/modules/homework/data-access" @@ -7,16 +8,16 @@ import { getCurrentStudentUser } from "@/modules/users/data-access" import { getTextbooks } from "@/modules/textbooks/data-access" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" -import { UserX } from "lucide-react" export const dynamic = "force-dynamic" export default async function StudentLearningPage() { + const t = await getTranslations("student") const student = await getCurrentStudentUser() if (!student) { return (
- +
) } @@ -40,33 +41,33 @@ export default async function StudentLearningPage() { const cards = [ { - title: "Courses", - description: "Your enrolled classes.", + title: t("learning.courses"), + description: t("learning.coursesDesc"), icon: BookOpen, href: "/student/learning/courses", - stat: `${classes.length} enrolled`, + stat: t("learning.enrolled", { count: classes.length }), }, { - title: "Assignments", - description: "Homework and practice.", + title: t("learning.assignments"), + description: t("learning.assignmentsDesc"), icon: PenTool, href: "/student/learning/assignments", - stat: `${pendingCount} pending${dueSoonCount > 0 ? ` · ${dueSoonCount} due soon` : ""}`, + stat: t("learning.pending", { count: pendingCount }) + (dueSoonCount > 0 ? t("learning.dueSoon", { count: dueSoonCount }) : ""), }, { - title: "Textbooks", - description: "Browse course materials.", + title: t("learning.textbooks"), + description: t("learning.textbooksDesc"), icon: Library, href: "/student/learning/textbooks", - stat: `${textbooks.length} available`, + stat: t("learning.available", { count: textbooks.length }), }, ] return (
-

My Learning

-

Your learning hub: courses, assignments, and textbooks.

+

{t("learning.title")}

+

{t("learning.description")}

diff --git a/src/app/(dashboard)/student/lesson-plans/[planId]/view/error.tsx b/src/app/(dashboard)/student/lesson-plans/[planId]/view/error.tsx new file mode 100644 index 0000000..e5d5a5b --- /dev/null +++ b/src/app/(dashboard)/student/lesson-plans/[planId]/view/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentLessonPlanViewError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/lesson-plans/[planId]/view/loading.tsx b/src/app/(dashboard)/student/lesson-plans/[planId]/view/loading.tsx new file mode 100644 index 0000000..be0bfcf --- /dev/null +++ b/src/app/(dashboard)/student/lesson-plans/[planId]/view/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function StudentLessonPlanViewLoading() { + return ( +
+
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx b/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx new file mode 100644 index 0000000..f6df55b --- /dev/null +++ b/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx @@ -0,0 +1,83 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { getTranslations } from "next-intl/server" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export const dynamic = "force-dynamic" + +export default async function StudentLessonPlanViewPage({ + params, +}: { + params: Promise<{ planId: string }> +}): Promise { + const { planId } = await params + const t = await getTranslations("lessonPreparation") + const ctx = await getAuthContext() + + const plan = await getLessonPlanById(planId, ctx.userId) + if (!plan) { + return ( +
+
+ {t("readonly.notFound")} +
+
+ ) + } + + // 学生只能查看已发布的课案 + if (plan.status !== "published") { + return ( +
+
+ {t("readonly.notPublished")} +
+
+ ) + } + + // 拉取教材/章节标题 + let textbookTitle: string | undefined + let chapterTitle: string | undefined + if (plan.textbookId) { + const textbook = await getTextbookById(plan.textbookId) + textbookTitle = textbook?.title + if (plan.chapterId) { + const chapters = await getChaptersByTextbookId(plan.textbookId) + const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { + for (const ch of list) { + if (ch.id === plan.chapterId) return ch + if (ch.children && ch.children.length > 0) { + const found = findChapter(ch.children as typeof chapters) + if (found) return found + } + } + return undefined + } + const chapter = findChapter(chapters) + chapterTitle = chapter?.title + } + } + + return ( +
+ + +
+ } + > + + +
+ ) +} diff --git a/src/app/(dashboard)/student/lesson-plans/error.tsx b/src/app/(dashboard)/student/lesson-plans/error.tsx new file mode 100644 index 0000000..bc460d3 --- /dev/null +++ b/src/app/(dashboard)/student/lesson-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentLessonPlansError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/lesson-plans/loading.tsx b/src/app/(dashboard)/student/lesson-plans/loading.tsx new file mode 100644 index 0000000..c251269 --- /dev/null +++ b/src/app/(dashboard)/student/lesson-plans/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function StudentLessonPlansLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/student/lesson-plans/page.tsx b/src/app/(dashboard)/student/lesson-plans/page.tsx new file mode 100644 index 0000000..22fe529 --- /dev/null +++ b/src/app/(dashboard)/student/lesson-plans/page.tsx @@ -0,0 +1,44 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { getTranslations } from "next-intl/server" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getLessonPlans } from "@/modules/lesson-preparation/data-access" +import { getSubjectOptions } from "@/modules/school/data-access" +import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export const dynamic = "force-dynamic" + +export default async function StudentLessonPlansPage(): Promise { + const t = await getTranslations("lessonPreparation") + const ctx = await getAuthContext() + + const [items, subjects] = await Promise.all([ + getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId), + getSubjectOptions(), + ]) + + return ( +
+
+

{t("student.title")}

+

{t("student.description")}

+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ } + > + + +
+ ) +} diff --git a/src/app/(dashboard)/student/practice/[sessionId]/error.tsx b/src/app/(dashboard)/student/practice/[sessionId]/error.tsx new file mode 100644 index 0000000..7529d42 --- /dev/null +++ b/src/app/(dashboard)/student/practice/[sessionId]/error.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useEffect } from "react" +import Link from "next/link" +import { useTranslations } from "next-intl" +import { Button } from "@/shared/components/ui/button" + +export default function PracticeSessionError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): React.ReactNode { + const t = useTranslations("student") + useEffect(() => { + console.error("Practice session error:", error) + }, [error]) + + return ( +
+

{t("error.title")}

+

{error.message}

+
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/student/practice/[sessionId]/loading.tsx b/src/app/(dashboard)/student/practice/[sessionId]/loading.tsx new file mode 100644 index 0000000..f864afc --- /dev/null +++ b/src/app/(dashboard)/student/practice/[sessionId]/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function PracticeSessionLoading(): React.ReactNode { + return ( +
+ + +
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/student/practice/[sessionId]/page.tsx b/src/app/(dashboard)/student/practice/[sessionId]/page.tsx new file mode 100644 index 0000000..072ad77 --- /dev/null +++ b/src/app/(dashboard)/student/practice/[sessionId]/page.tsx @@ -0,0 +1,31 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import { getPracticeSessionById } from "@/modules/adaptive-practice/data-access" +import { PracticeSessionView } from "@/modules/adaptive-practice/components/practice-session-view" + +export const dynamic = "force-dynamic" + +export default async function PracticeSessionPage({ + params, +}: { + params: Promise<{ sessionId: string }> +}): Promise { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + const { sessionId } = await params + + const session = await getPracticeSessionById(sessionId, ctx.userId) + + if (!session) { + notFound() + } + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/student/practice/error.tsx b/src/app/(dashboard)/student/practice/error.tsx new file mode 100644 index 0000000..33012d5 --- /dev/null +++ b/src/app/(dashboard)/student/practice/error.tsx @@ -0,0 +1,26 @@ +"use client" + +import { useEffect } from "react" +import { useTranslations } from "next-intl" +import { Button } from "@/shared/components/ui/button" + +export default function PracticeError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): React.ReactNode { + const t = useTranslations("student") + useEffect(() => { + console.error("Practice page error:", error) + }, [error]) + + return ( +
+

{t("error.title")}

+

{error.message}

+ +
+ ) +} diff --git a/src/app/(dashboard)/student/practice/loading.tsx b/src/app/(dashboard)/student/practice/loading.tsx new file mode 100644 index 0000000..f902091 --- /dev/null +++ b/src/app/(dashboard)/student/practice/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function PracticeLoading(): React.ReactNode { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/student/practice/page.tsx b/src/app/(dashboard)/student/practice/page.tsx new file mode 100644 index 0000000..f562580 --- /dev/null +++ b/src/app/(dashboard)/student/practice/page.tsx @@ -0,0 +1,45 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access" +import { PracticeStarter } from "@/modules/adaptive-practice/components/practice-starter" +import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history" +import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards" +import { getKnowledgePointOptions } from "@/modules/questions/data-access" + +export const dynamic = "force-dynamic" + +export default async function StudentPracticePage(): Promise { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + const t = await getTranslations("practice") + + const [stats, sessionsResult, knowledgePoints] = await Promise.all([ + getPracticeStats(ctx.userId), + getPracticeSessions(ctx.userId, { pageSize: 20 }), + getKnowledgePointOptions(), + ]) + + return ( +
+
+

{t("page.title")}

+

{t("page.description")}

+
+ + + +
+
+ +
+
+

{t("history.title")}

+ +
+
+
+ ) +} diff --git a/src/app/(dashboard)/student/schedule/page.tsx b/src/app/(dashboard)/student/schedule/page.tsx index a011fde..2c178eb 100644 --- a/src/app/(dashboard)/student/schedule/page.tsx +++ b/src/app/(dashboard)/student/schedule/page.tsx @@ -1,4 +1,5 @@ import { UserX } from "lucide-react" +import { getTranslations } from "next-intl/server" import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" @@ -14,15 +15,20 @@ export default async function StudentSchedulePage({ }: { searchParams: Promise }) { + const t = await getTranslations("student") const student = await getCurrentStudentUser() if (!student) { return (
-

Schedule

-

Your weekly timetable.

+

{t("schedule.title")}

+

{t("schedule.description")}

- +
) } @@ -41,8 +47,8 @@ export default async function StudentSchedulePage({
-

Schedule

-

Your weekly timetable.

+

{t("schedule.title")}

+

{t("schedule.description")}

diff --git a/src/app/(dashboard)/teacher/error-book/page.tsx b/src/app/(dashboard)/teacher/error-book/page.tsx index 6da026d..750b9b9 100644 --- a/src/app/(dashboard)/teacher/error-book/page.tsx +++ b/src/app/(dashboard)/teacher/error-book/page.tsx @@ -1,36 +1,52 @@ import type { JSX } from "react" +import { Suspense } from "react" import { BarChart3 } from "lucide-react" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access" import { getStudentErrorBookSummaries, getTopWrongQuestionsByStudentIds, getKnowledgePointWeakness, - getSubjectErrorDistribution, getStudentNameMap, + getSubjectErrorOverviews, + getClassErrorOverviews, + getChapterWeakness, } from "@/modules/error-book/data-access" -import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview" import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" +import { SubjectTabs } from "@/modules/error-book/components/subject-tabs" +import { ClassFilter } from "@/modules/error-book/components/class-filter" +import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards" +import { ClassErrorBarChart } from "@/modules/error-book/components/class-error-bar-chart" +import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart" +import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart" +import { GroupedStudentErrorTable } from "@/modules/error-book/components/grouped-student-error-table" export const dynamic = "force-dynamic" -export default async function TeacherErrorBookPage(): Promise { +async function TeacherErrorBookContent({ + searchParams, +}: { + searchParams: Promise +}): Promise { const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ) - // 教师的 dataScope 为 class_taught,年级主任/教研组长为 grade_managed,管理员为 all + const params = await searchParams const classIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : [] const gradeIds = ctx.dataScope.type === "grade_managed" ? ctx.dataScope.gradeIds : [] + const teacherSubjectIds = ctx.dataScope.type === "class_taught" ? (ctx.dataScope.subjectIds ?? []) : [] if (classIds.length === 0 && gradeIds.length === 0 && ctx.dataScope.type !== "all") { return (

错题分析

-

查看班级学生的错题统计与薄弱知识点。

+

按学科、班级查看学生的错题统计与薄弱知识点。

{ ) } - // 年级主任/教研组长:先根据 gradeIds 查询班级,再查询学生 + // 年级主任/教研组长:展开年级为班级 let targetClassIds = classIds if (gradeIds.length > 0) { const gradeClassIds = await getClassIdsByGradeIds(gradeIds) targetClassIds = [...new Set([...classIds, ...gradeClassIds])] } - const studentIds = await getStudentIdsByClassIds(targetClassIds) + // 获取所有学生 ID(用于学科概览查询) + const allStudentIds = await getStudentIdsByClassIds(targetClassIds) - if (studentIds.length === 0) { + if (allStudentIds.length === 0) { return (

错题分析

-

查看班级学生的错题统计与薄弱知识点。

+

按学科、班级查看学生的错题统计与薄弱知识点。

{ ) } - // 并行查询所有统计数据 - const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([ - getStudentErrorBookSummaries(studentIds), - getTopWrongQuestionsByStudentIds(studentIds, 10), - getKnowledgePointWeakness(studentIds, 10), - getSubjectErrorDistribution(studentIds), - getStudentNameMap(studentIds), + // 解析 URL 参数:学科筛选 + 班级筛选 + const subjectParam = getParam(params, "subject") + const classParam = getParam(params, "classId") + + // 学科概览(用于 Tab 显示,不受学科筛选影响) + const subjectOverviews = await getSubjectErrorOverviews(allStudentIds) + + // 班级概览(用于班级筛选器显示,受学科筛选影响) + const classOverviews = await getClassErrorOverviews(targetClassIds, subjectParam) + + // 确定实际查询的学科和班级 + // 如果教师有所教学科,默认只显示所教学科;否则显示全部 + const effectiveSubjectId = + subjectParam ?? (teacherSubjectIds.length === 1 ? teacherSubjectIds[0] : null) + const effectiveClassId = classParam ?? "all" + + // 确定查询的学生范围(按班级筛选) + const queryClassIds = effectiveClassId === "all" ? targetClassIds : [effectiveClassId] + const queryStudentIds = await getStudentIdsByClassIds(queryClassIds) + + // 并行查询所有统计数据(按学科+班级过滤) + const [summaries, topWrongQuestions, weakKps, chapterWeakness, nameMap] = await Promise.all([ + getStudentErrorBookSummaries(queryStudentIds, effectiveSubjectId), + getTopWrongQuestionsByStudentIds(queryStudentIds, 10, effectiveSubjectId), + getKnowledgePointWeakness(queryStudentIds, 10, effectiveSubjectId), + getChapterWeakness(queryStudentIds, 10, effectiveSubjectId), + getStudentNameMap(queryStudentIds), ]) const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0) const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0) + const totalDueReview = summaries.reduce((sum, s) => sum + s.dueReviewCount, 0) const averageMasteryRate = studentsWithErrorBook.length > 0 ? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length : 0 + const knowledgePointCount = weakKps.length // 按错题数降序排列 const sortedSummaries = [...summaries].sort((a, b) => b.totalCount - a.totalCount) return ( -
+

错题分析

- 查看班级学生的错题统计与薄弱知识点,辅助精准教学。 + 按学科、班级查看学生的错题统计与薄弱知识点,辅助精准教学。

- 0 ? ( + }> + + + ) : null} + + {/* 班级筛选器 */} + {classOverviews.length > 0 ? ( + }> + + + ) : null} + + {/* 统计卡片 */} + -
-

学生错题详情

- + {/* 班级错题对比图(仅在"全部班级"视图下显示) */} + {effectiveClassId === "all" && classOverviews.length > 1 ? ( + + ) : null} + + {/* 章节错题分布 + 知识点薄弱度(并排) */} +
+ {chapterWeakness.length > 0 ? ( + + ) : ( + + )} + {weakKps.length > 0 ? ( + + ) : ( + + )}
- + {/* 学生错题详情(按班级分组) */} +
+
+

学生错题详情

+ + 共 {queryStudentIds.length} 名学生,{studentsWithErrorBook.length} 名有错题 + +
+ {sortedSummaries.length > 0 ? ( + + ) : ( + + )} +
+ + {/* 高频错题 Top 10 */} + {topWrongQuestions.length > 0 ? ( + + ) : null}
) } + +export default async function TeacherErrorBookPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + return ( + + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ } + > + + + ) +} diff --git a/src/app/(dashboard)/teacher/grades/entry/page.tsx b/src/app/(dashboard)/teacher/grades/entry/page.tsx index 0a771af..9890ce0 100644 --- a/src/app/(dashboard)/teacher/grades/entry/page.tsx +++ b/src/app/(dashboard)/teacher/grades/entry/page.tsx @@ -1,8 +1,8 @@ import type { JSX } from "react" -import { getTeacherClasses } from "@/modules/classes/data-access" +import { getTeacherClasses, getClassGradeIdsByClassIds } from "@/modules/classes/data-access" import { getClassStudentsForEntry } from "@/modules/grades/data-access" -import { getSubjectOptions } from "@/modules/school/data-access" -import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry" +import { getExamsForGradeEntry, getExamForGradeEntry } from "@/modules/exams/data-access" +import { BatchGradeEntryByExam } from "@/modules/grades/components/batch-grade-entry" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" @@ -19,63 +19,73 @@ export default async function BatchEntryPage({ const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE) const sp = await searchParams - const defaultClassId = getParam(sp, "classId") - const defaultSubjectId = getParam(sp, "subjectId") + const examId = getParam(sp, "examId") + const classId = getParam(sp, "classId") - // P3 修复:添加 scope 校验,对 class_taught scope 限制可录入的班级 - const [classes, allSubjects, students] = await Promise.all([ + // 获取试卷列表 + 班级列表 + const [exams, teacherClasses] = await Promise.all([ + getExamsForGradeEntry(ctx.dataScope), getTeacherClasses(), - getSubjectOptions(), - defaultClassId - ? getClassStudentsForEntry(defaultClassId, ctx.dataScope) - : Promise.resolve([] as Awaited>), ]) - // 对 class_taught scope,过滤掉不在 scope 中的班级 + // scope 过滤班级 const allowedClassIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null const scopedClasses = allowedClassIds - ? classes.filter((c) => allowedClassIds.includes(c.id)) - : classes + ? teacherClasses.filter((c) => allowedClassIds.includes(c.id)) + : teacherClasses const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name })) - const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) - // 如果指定了 classId 但 scope 不允许,显示提示 - if (defaultClassId && students.length === 0 && scopedClasses.length > 0) { - const classExists = scopedClasses.some((c) => c.id === defaultClassId) - if (!classExists) { - return ( -
-
-

Batch Grade Entry

-

Enter grades for all students in a class at once.

-
- -
- ) + // 获取 classId → gradeId 映射(用于客户端按试卷年级过滤班级) + const classGradeMap: Record = {} + if (scopedClasses.length > 0) { + const gradeMap = await getClassGradeIdsByClassIds( + scopedClasses.map((c) => c.id) + ) + for (const [cid, gid] of gradeMap.entries()) { + classGradeMap[cid] = gid } } + // 如果有 examId,获取试卷详情(含题目列表) + const exam = examId + ? await getExamForGradeEntry(examId, ctx.dataScope) + : null + + // 如果有 examId + classId,获取学生列表 + const students = + examId && classId + ? await getClassStudentsForEntry(classId, ctx.dataScope) + : [] + return (
-

Batch Grade Entry

-

Enter grades for all students in a class at once.

+

批量录入成绩

+

+ 从试卷库选择试卷,按每题得分录入,像填 Excel 表格一样。 +

- + {exams.length === 0 ? ( + + ) : ( + + )}
) } diff --git a/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/error.tsx new file mode 100644 index 0000000..c5b88e0 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function EditLessonPlanError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/loading.tsx new file mode 100644 index 0000000..50b8559 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherEditLessonPlanLoading() { + return ( +
+
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx index 0b37a88..bfc5b75 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react" import { notFound } from "next/navigation" import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor" +import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup" import { getTeacherClasses } from "@/modules/classes/data-access" import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" import { getAuthContext } from "@/shared/lib/auth-guard" @@ -82,26 +83,29 @@ export default async function EditLessonPlanPage({ return ( -
- - -
- } - > - - -
+ +
+ + +
+ } + > + + +
+ ) } diff --git a/src/app/(dashboard)/teacher/lesson-plans/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/error.tsx new file mode 100644 index 0000000..c7c7476 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function LessonPlansError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/loading.tsx new file mode 100644 index 0000000..29bc2d2 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherLessonPlansLoading() { + return ( +
+
+
+ + +
+ +
+
+ + + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/new/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/new/error.tsx new file mode 100644 index 0000000..4576219 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/new/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BookOpen } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function NewLessonPlanError() { + const t = useTranslations("lessonPreparation") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/new/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/new/loading.tsx new file mode 100644 index 0000000..b99adea --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/new/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherNewLessonPlanLoading() { + return ( +
+
+ + +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx index 582c72b..543555b 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx @@ -6,6 +6,7 @@ import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { Skeleton } from "@/shared/components/ui/skeleton" import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker" +import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup" export const dynamic = "force-dynamic" @@ -22,20 +23,22 @@ export default async function NewLessonPlanPage(): Promise {

{t("title.new")}

- - -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} + + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
-
- } - > - - + } + > + + +
) } diff --git a/src/app/(dashboard)/teacher/lesson-plans/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/page.tsx index a27141b..866db4e 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/page.tsx @@ -9,6 +9,7 @@ import { getAuthContext } from "@/shared/lib/auth-guard" import { getLessonPlans } from "@/modules/lesson-preparation/data-access" import { getSubjectOptions } from "@/modules/school/data-access" import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup" export const dynamic = "force-dynamic" @@ -37,24 +38,26 @@ export default async function LessonPlansPage(): Promise {
- -
- - - + + +
+ + + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
-
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- } - > - - + } + > + + +
) } diff --git a/src/app/(dashboard)/teacher/practice/error.tsx b/src/app/(dashboard)/teacher/practice/error.tsx new file mode 100644 index 0000000..a2812d9 --- /dev/null +++ b/src/app/(dashboard)/teacher/practice/error.tsx @@ -0,0 +1,27 @@ +"use client" + +import { useEffect } from "react" +import { BarChart3 } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function Error() { + useEffect(() => { + console.error("Practice analytics page error") + }, []) + + return ( +
+
+

专项练习分析

+

加载练习分析数据时发生错误

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/practice/loading.tsx b/src/app/(dashboard)/teacher/practice/loading.tsx new file mode 100644 index 0000000..0490a13 --- /dev/null +++ b/src/app/(dashboard)/teacher/practice/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/practice/page.tsx b/src/app/(dashboard)/teacher/practice/page.tsx new file mode 100644 index 0000000..1246d9f --- /dev/null +++ b/src/app/(dashboard)/teacher/practice/page.tsx @@ -0,0 +1,273 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { Suspense } from "react" +import { BarChart3 } from "lucide-react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { + getClassIdsByGradeIds, + getStudentIdsByClassIds, +} from "@/modules/classes/data-access" + +import { + getTeacherClassPracticeOverviews, + getClassStudentPracticeSummaries, + getPracticeTypeBreakdown, + getClassKnowledgePointWeakness, + getStudentsWithoutPractice, + getStudentNameMap, +} from "@/modules/adaptive-practice/data-access-analytics" +import { PracticeOverviewStatsCards } from "@/modules/adaptive-practice/components/practice-overview-stats-cards" +import { ClassPracticeComparisonTable } from "@/modules/adaptive-practice/components/class-practice-comparison-table" +import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/components/practice-type-breakdown-chart" +import { ClassKnowledgePointWeaknessChart } from "@/modules/adaptive-practice/components/class-knowledge-point-weakness-chart" +import { StudentPracticeRankingTable } from "@/modules/adaptive-practice/components/student-practice-ranking-table" +import { InactiveStudentsAlert } from "@/modules/adaptive-practice/components/inactive-students-alert" +import { ClassFilter } from "@/modules/error-book/components/class-filter" +import type { ClassErrorOverview } from "@/modules/error-book/types" + +export const dynamic = "force-dynamic" + +export async function generateMetadata(): Promise { + const t = await getTranslations("practice") + return { + title: `${t("teacher.title")} - Next_Edu`, + description: t("teacher.description"), + } +} + +async function TeacherPracticeContent({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + const t = await getTranslations("practice") + + const params = await searchParams + const classIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : [] + const gradeIds = ctx.dataScope.type === "grade_managed" ? ctx.dataScope.gradeIds : [] + + if (classIds.length === 0 && gradeIds.length === 0 && ctx.dataScope.type !== "all") { + return ( +
+
+

{t("teacher.title")}

+

{t("teacher.description")}

+
+ +
+ ) + } + + // 年级主任/教研组长:展开年级为班级 + let targetClassIds = classIds + if (gradeIds.length > 0) { + const gradeClassIds = await getClassIdsByGradeIds(gradeIds) + targetClassIds = [...new Set([...classIds, ...gradeClassIds])] + } + + // 获取所有学生 ID + const allStudentIds = await getStudentIdsByClassIds(targetClassIds) + + if (allStudentIds.length === 0) { + return ( +
+
+

{t("teacher.title")}

+

{t("teacher.description")}

+
+ +
+ ) + } + + // 解析 URL 参数:班级筛选 + const classParam = getParam(params, "classId") + const effectiveClassId = classParam ?? "all" + + // 班级概览(用于班级筛选器显示) + const classOverviews = await getTeacherClassPracticeOverviews(targetClassIds) + + // 构造 ClassFilter 所需的数据格式 + const classFilterData: ClassErrorOverview[] = classOverviews.map((c) => ({ + classId: c.classId, + className: c.className, + studentCount: c.totalStudents, + totalErrorItems: c.totalSessions, + dueReviewCount: 0, + averageErrorPerStudent: c.totalStudents > 0 ? c.totalSessions / c.totalStudents : 0, + averageMasteryRate: c.averageAccuracy, + })) + + // 确定查询的班级范围 + const queryClassIds = effectiveClassId === "all" ? targetClassIds : [effectiveClassId] + const queryStudentIds = await getStudentIdsByClassIds(queryClassIds) + + // 并行查询所有统计数据 + const [typeBreakdown, studentSummaries, nameMap, inactiveIds] = await Promise.all([ + getPracticeTypeBreakdown(queryStudentIds), + // 按班级查询学生摘要(如果是单班级视图,直接查询;如果是全部视图,按班级逐个查询后合并) + effectiveClassId === "all" + ? getClassStudentPracticeSummariesForClasses(queryClassIds) + : getClassStudentPracticeSummaries(effectiveClassId), + getStudentNameMap(queryStudentIds), + effectiveClassId === "all" + ? getInactiveStudentsForClasses(queryClassIds) + : getStudentsWithoutPractice(effectiveClassId), + ]) + + // 聚合统计 + const totalSessions = classOverviews.reduce((sum, c) => sum + c.totalSessions, 0) + const totalAnswered = classOverviews.reduce((sum, c) => sum + c.totalQuestionsAnswered, 0) + const totalCorrect = classOverviews.reduce((sum, c) => sum + c.totalCorrect, 0) + const totalActiveStudents = classOverviews.reduce((sum, c) => sum + c.activeStudents, 0) + const totalStudents = classOverviews.reduce((sum, c) => sum + c.totalStudents, 0) + const averageAccuracy = totalAnswered > 0 ? totalCorrect / totalAnswered : 0 + const participationRate = totalStudents > 0 ? totalActiveStudents / totalStudents : 0 + + // 单班级视图:查询知识点薄弱度 + const weakKps = effectiveClassId !== "all" + ? await getClassKnowledgePointWeakness(effectiveClassId, 10) + : [] + + const hasData = totalSessions > 0 + + if (!hasData) { + return ( +
+
+

{t("teacher.title")}

+

{t("teacher.description")}

+
+ +
+ ) + } + + return ( +
+
+

{t("teacher.title")}

+

{t("teacher.description")}

+
+ + {/* 班级筛选器 */} + {classFilterData.length > 0 ? ( + }> + + + ) : null} + + {/* 统计卡片 */} + + + {/* 班级对比表(仅在"全部班级"视图下显示) */} + {effectiveClassId === "all" && classOverviews.length > 1 ? ( + + ) : null} + + {/* 练习类型分布图 */} + {typeBreakdown.length > 0 ? ( + + ) : null} + + {/* 知识点薄弱度(仅在单班级视图下显示) */} + {effectiveClassId !== "all" ? ( + + ) : null} + + {/* 学生练习排名 */} + {studentSummaries.length > 0 ? ( + + ) : null} + + {/* 未参与练习学生提醒 */} + +
+ ) +} + +/** + * 获取多个班级的学生练习摘要(合并结果)。 + * + * 用于"全部班级"视图,按班级逐个查询后合并。 + */ +async function getClassStudentPracticeSummariesForClasses( + classIds: string[], +) { + const results = await Promise.all( + classIds.map((classId) => getClassStudentPracticeSummaries(classId)), + ) + // 合并并按练习数降序排列 + return results.flat().sort((a, b) => b.totalSessions - a.totalSessions) +} + +/** + * 获取多个班级中未参与练习的学生 ID 列表(合并结果)。 + */ +async function getInactiveStudentsForClasses( + classIds: string[], +): Promise { + const results = await Promise.all( + classIds.map((classId) => getStudentsWithoutPractice(classId)), + ) + return results.flat() +} + +export default async function TeacherPracticePage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + return ( + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ } + > + + + ) +} diff --git a/src/app/globals.css b/src/app/globals.css index 87217eb..63efbdd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -149,6 +149,21 @@ --color-sidebar-border: hsl(var(--sidebar-border)); --color-sidebar-ring: hsl(var(--sidebar-ring)); + /* Material Design 3 Surface 令牌映射(备课模块使用)*/ + --color-surface: hsl(var(--card)); + --color-on-surface: hsl(var(--foreground)); + --color-on-surface-variant: hsl(var(--muted-foreground)); + --color-surface-container-lowest: hsl(var(--background)); + --color-surface-container-low: hsl(var(--muted)); + --color-surface-container: hsl(var(--secondary)); + --color-surface-container-high: hsl(var(--accent)); + --color-surface-container-highest: hsl(var(--muted-foreground)); + --color-outline-variant: hsl(var(--border)); + --color-outline: hsl(var(--border)); + --color-error: hsl(var(--destructive)); + --color-tertiary: hsl(var(--chart-3)); + --color-tertiary-container: hsl(var(--chart-3)); + --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); @@ -241,3 +256,9 @@ .anchor-edge.active { opacity: 1; } + +/* 正文节点光标指示器闪烁 */ +@keyframes cursor-blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +}