diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 09eb867..66f448e 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -1119,15 +1119,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" |------|------|------| | `data-access.ts` | 243 | 子女关系 + 仪表盘数据聚合 + 关系校验 + 子女姓名列表(v4 新增 `getChildNameList` + `buildWeeklySchedule`) | | `types.ts` | 79 | 类型定义(含 JSDoc,v4 新增 `ChildWeeklyScheduleItem`) | -| `components/parent-dashboard.tsx` | 97 | 仪表盘(v4 重构:待办横幅 + 宫格快捷入口) | -| `components/parent-attention-banner.tsx` | 116 | v4 新增:待办事项/异常聚合横幅 | +| `components/parent-dashboard.tsx` | 110 | 仪表盘(v4 重构:待办横幅 + 宫格快捷入口 + 移动端水平滑动) | +| `components/parent-attention-banner.tsx` | 128 | v4 新增:待办事项/异常聚合横幅(作业项直接跳转详情页 homework tab) | | `components/parent-attendance-warning.tsx` | 89 | v4 新增:考勤异常预警 | +| `components/parent-attendance-rate-card.tsx` | 105 | v4 新增:考勤出勤率汇总卡片 | +| `components/parent-attendance-calendar.tsx` | 175 | v4 新增:考勤月历视图(use client) | | `components/parent-export-button.tsx` | 50 | v4 新增:成绩导出按钮(占位) | | `components/child-card.tsx` | 148 | 子女卡片(v4 增强:异常突出 + 趋势图标) | | `components/child-detail-header.tsx` | 78 | 详情页头部(v4 增强:面包屑) | -| `components/child-detail-panel.tsx` | 187 | 详情页 Tab 面板 + SiblingSwitcher(v4 重写) | -| `components/child-homework-summary.tsx` | 147 | 作业摘要(v4 增强:科目标识 + 触摸区域) | +| `components/child-detail-panel.tsx` | 200 | 详情页 Tab 面板 + SiblingSwitcher(v4 重写,集成 Homework/Grade Detail) | +| `components/child-homework-summary.tsx` | 147 | 作业摘要(v4 增强:科目标识 + 触摸区域 + pts 单位) | +| `components/child-homework-detail.tsx` | 145 | v4 新增:作业详情视图(完整作业信息) | | `components/child-grade-summary.tsx` | 159 | 成绩趋势(v4 增强:趋势图标 + aria-label) | +| `components/child-grade-detail.tsx` | 165 | v4 新增:成绩详情视图(按科目分组分析) | | `components/child-schedule-card.tsx` | 119 | 课表卡片(v4 增强:周课表视图) | | `components/parent-children-data-page.tsx` | 92 | 共享数据页(v4 增强:headerExtra) | diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 8785b14..e70172a 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -12247,11 +12247,11 @@ } }, "classes": { - "description": "班级/选课/课表", + "description": "班级/选课/课表/邀请码(v3 新增邀请码体系,对标 Google Classroom / 钉钉教育)", "tables": { "classes": { "owner": "classes", - "description": "班级" + "description": "班级(invitationCode 字段保留作为 fallback,v3 迁移至 classInvitationCodes 表)" }, "classSubjectTeachers": { "owner": "classes", @@ -12264,6 +12264,10 @@ "classSchedule": { "owner": "scheduling", "description": "课表(注意:三处写入口,见 knownIssues P0-6)" + }, + "classInvitationCodes": { + "owner": "classes", + "description": "v3 新增:班级邀请码(独立表,支持有效期/次数限制/审计/多码并存,6 位字母数字剔除歧义字符)" } } }, @@ -12601,6 +12605,48 @@ "v3 P1-4 家长多子女绑定:动态多行 UI,支持一次绑定多个子女", "v3 P1-5 跳过机制明确化:parent 不可跳过子女绑定(核心功能)" ] + }, + "i18n": { + "description": "v3 新增:项目国际化体系(next-intl 4.x,without i18n routing 模式,cookie 驱动)", + "tables": {}, + "exports": { + "config": [ + { + "name": "getRequestConfig", + "file": "src/i18n/request.ts", + "purpose": "next-intl 请求配置:从 cookie 读取 locale,按命名空间加载字典" + }, + { + "name": "setLocaleAction", + "file": "src/i18n/actions.ts", + "purpose": "Server Action:切换语言(写 cookie + revalidatePath)" + } + ], + "shared": [ + { + "name": "LOCALES / Locale / DEFAULT_LOCALE / LOCALE_COOKIE", + "file": "src/shared/i18n/locale.ts", + "purpose": "locale 常量与工具函数" + }, + { + "name": "LocaleSwitcher", + "file": "src/shared/components/locale-switcher.tsx", + "purpose": "语言切换组件(DropdownMenu + cookie 持久化)" + } + ], + "messages": [ + "src/shared/i18n/messages/zh-CN/{common,auth,onboarding,classes,errors}.json", + "src/shared/i18n/messages/en/{common,auth,onboarding,classes,errors}.json" + ] + }, + "routes": {}, + "securityNotes": [ + "不使用 URL 路由段,避免破坏现有 (auth)/(dashboard)/(onboarding) 路由组结构", + "locale 通过 cookie 持久化,SSR 时由 getRequestConfig 读取", + "不使用 Accept-Language 自动协商,避免 SSR 与客户端 hydration 不一致", + "字典放在 shared/i18n/messages/,符合三层架构约束(shared 为被依赖方)", + "NextIntlClientProvider 在根 layout 注入,服务端/客户端组件均可使用 useTranslations" + ] } }, "dependencyMatrix": { diff --git a/src/app/(dashboard)/admin/announcements/[id]/page.tsx b/src/app/(dashboard)/admin/announcements/[id]/page.tsx index a6cc047..d7504ca 100644 --- a/src/app/(dashboard)/admin/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/admin/announcements/[id]/page.tsx @@ -2,6 +2,8 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getAnnouncementById } from "@/modules/announcements/data-access" import { getGrades } from "@/modules/school/data-access" import { AnnouncementForm } from "@/modules/announcements/components/announcement-form" @@ -18,6 +20,7 @@ export default async function EditAnnouncementPage({ }: { params: Promise<{ id: string }> }): Promise { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const { id } = await params const [announcement, grades] = await Promise.all([ diff --git a/src/app/(dashboard)/admin/announcements/loading.tsx b/src/app/(dashboard)/admin/announcements/loading.tsx new file mode 100644 index 0000000..4ca3b4d --- /dev/null +++ b/src/app/(dashboard)/admin/announcements/loading.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminAnnouncementsLoading() { + return ( +
+
+
+ + +
+ +
+ +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + +
+ + +
+
+
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/announcements/page.tsx b/src/app/(dashboard)/admin/announcements/page.tsx index 794914b..491c259 100644 --- a/src/app/(dashboard)/admin/announcements/page.tsx +++ b/src/app/(dashboard)/admin/announcements/page.tsx @@ -1,8 +1,11 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getAnnouncements } from "@/modules/announcements/data-access" import { getGrades } from "@/modules/school/data-access" +import { getAdminClasses } from "@/modules/classes/data-access" import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import type { AnnouncementStatus } from "@/modules/announcements/types" @@ -22,19 +25,22 @@ export default async function AdminAnnouncementsPage({ }: { searchParams: Promise }): Promise { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const sp = await searchParams const statusParam = getSearchParam(sp, "status") const status = isValidStatus(statusParam) ? statusParam : undefined - const [announcements, grades] = await Promise.all([ + const [announcements, grades, classes] = await Promise.all([ getAnnouncements({ status }), getGrades(), + getAdminClasses(), ]) return ( ({ id: g.id, name: g.name }))} + classes={classes.map((c) => ({ id: c.id, name: c.name }))} initialStatus={status} /> ) diff --git a/src/app/(dashboard)/admin/layout.tsx b/src/app/(dashboard)/admin/layout.tsx new file mode 100644 index 0000000..d712a5b --- /dev/null +++ b/src/app/(dashboard)/admin/layout.tsx @@ -0,0 +1,10 @@ +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/scheduling/auto/error.tsx b/src/app/(dashboard)/admin/scheduling/auto/error.tsx new file mode 100644 index 0000000..79af8df --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/auto/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminSchedulingAutoError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/auto/loading.tsx b/src/app/(dashboard)/admin/scheduling/auto/loading.tsx new file mode 100644 index 0000000..b1f109e --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/auto/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminSchedulingAutoLoading() { + return ( +
+
+ + +
+ + + + + + + {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 80452a1..7349838 100644 --- a/src/app/(dashboard)/admin/scheduling/auto/page.tsx +++ b/src/app/(dashboard)/admin/scheduling/auto/page.tsx @@ -3,6 +3,8 @@ import { CalendarClock, ClipboardList, Settings2 } from "lucide-react" import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" import { getAdminClassesForScheduling } from "@/modules/scheduling/data-access" @@ -16,6 +18,7 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminSchedulingAutoPage(): Promise { + await requirePermission(Permissions.SCHEDULE_AUTO) const classes = await getAdminClassesForScheduling() const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade })) diff --git a/src/app/(dashboard)/admin/scheduling/changes/error.tsx b/src/app/(dashboard)/admin/scheduling/changes/error.tsx new file mode 100644 index 0000000..27f6de8 --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/changes/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminSchedulingChangesError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/changes/loading.tsx b/src/app/(dashboard)/admin/scheduling/changes/loading.tsx new file mode 100644 index 0000000..3daeabb --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/changes/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminSchedulingChangesLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/rules/error.tsx b/src/app/(dashboard)/admin/scheduling/rules/error.tsx new file mode 100644 index 0000000..ca7f445 --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/rules/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminSchedulingRulesError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/rules/loading.tsx b/src/app/(dashboard)/admin/scheduling/rules/loading.tsx new file mode 100644 index 0000000..375b118 --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/rules/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminSchedulingRulesLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/rules/page.tsx b/src/app/(dashboard)/admin/scheduling/rules/page.tsx index 383de8a..70c3053 100644 --- a/src/app/(dashboard)/admin/scheduling/rules/page.tsx +++ b/src/app/(dashboard)/admin/scheduling/rules/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { EmptyState } from "@/shared/components/ui/empty-state" import { getAdminClassesForScheduling, @@ -17,6 +19,7 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminSchedulingRulesPage(): Promise { + await requirePermission(Permissions.SCHEDULE_ADJUST) const [classes, existingRules] = await Promise.all([ getAdminClassesForScheduling(), getSchedulingRules(), diff --git a/src/app/(dashboard)/admin/school/academic-year/error.tsx b/src/app/(dashboard)/admin/school/academic-year/error.tsx new file mode 100644 index 0000000..c2ed1bc --- /dev/null +++ b/src/app/(dashboard)/admin/school/academic-year/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminAcademicYearError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/academic-year/loading.tsx b/src/app/(dashboard)/admin/school/academic-year/loading.tsx new file mode 100644 index 0000000..76606d2 --- /dev/null +++ b/src/app/(dashboard)/admin/school/academic-year/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminAcademicYearLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/academic-year/page.tsx b/src/app/(dashboard)/admin/school/academic-year/page.tsx index 504c2c9..bf3977e 100644 --- a/src/app/(dashboard)/admin/school/academic-year/page.tsx +++ b/src/app/(dashboard)/admin/school/academic-year/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { AcademicYearClient } from "@/modules/school/components/academic-year-view" import { getAcademicYears } from "@/modules/school/data-access" @@ -12,6 +14,7 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminAcademicYearPage(): Promise { + await requirePermission(Permissions.SCHOOL_MANAGE) const years = await getAcademicYears() return (
diff --git a/src/app/(dashboard)/admin/school/classes/error.tsx b/src/app/(dashboard)/admin/school/classes/error.tsx new file mode 100644 index 0000000..f847201 --- /dev/null +++ b/src/app/(dashboard)/admin/school/classes/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminClassesError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/classes/loading.tsx b/src/app/(dashboard)/admin/school/classes/loading.tsx new file mode 100644 index 0000000..10656d7 --- /dev/null +++ b/src/app/(dashboard)/admin/school/classes/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminClassesLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/classes/page.tsx b/src/app/(dashboard)/admin/school/classes/page.tsx index c7f13c5..4e6f9f8 100644 --- a/src/app/(dashboard)/admin/school/classes/page.tsx +++ b/src/app/(dashboard)/admin/school/classes/page.tsx @@ -1,7 +1,10 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access" +import { getGrades, getSchools } from "@/modules/school/data-access" import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view" export const metadata: Metadata = { @@ -12,7 +15,13 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminSchoolClassesPage(): Promise { - const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()]) + await requirePermission(Permissions.SCHOOL_MANAGE) + const [classes, teachers, schools, grades] = await Promise.all([ + getAdminClasses(), + getTeacherOptions(), + getSchools(), + getGrades(), + ]) return (
@@ -20,7 +29,7 @@ export default async function AdminSchoolClassesPage(): Promise {

班级管理

管理班级并分配教师。

- +
) } diff --git a/src/app/(dashboard)/admin/school/departments/error.tsx b/src/app/(dashboard)/admin/school/departments/error.tsx new file mode 100644 index 0000000..3cc3863 --- /dev/null +++ b/src/app/(dashboard)/admin/school/departments/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminDepartmentsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/departments/loading.tsx b/src/app/(dashboard)/admin/school/departments/loading.tsx new file mode 100644 index 0000000..38f7015 --- /dev/null +++ b/src/app/(dashboard)/admin/school/departments/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminDepartmentsLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/departments/page.tsx b/src/app/(dashboard)/admin/school/departments/page.tsx index fab3561..74daa38 100644 --- a/src/app/(dashboard)/admin/school/departments/page.tsx +++ b/src/app/(dashboard)/admin/school/departments/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { DepartmentsClient } from "@/modules/school/components/departments-view" import { getDepartments } from "@/modules/school/data-access" @@ -12,6 +14,7 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminDepartmentsPage(): Promise { + await requirePermission(Permissions.SCHOOL_MANAGE) const departments = await getDepartments() return (
diff --git a/src/app/(dashboard)/admin/school/grades/error.tsx b/src/app/(dashboard)/admin/school/grades/error.tsx new file mode 100644 index 0000000..54f48b9 --- /dev/null +++ b/src/app/(dashboard)/admin/school/grades/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminGradesError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/grades/insights/page.tsx b/src/app/(dashboard)/admin/school/grades/insights/page.tsx index b7c8160..687f7c1 100644 --- a/src/app/(dashboard)/admin/school/grades/insights/page.tsx +++ b/src/app/(dashboard)/admin/school/grades/insights/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from "next" import type { JSX } from "react" import { BarChart3 } from "lucide-react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getGrades } from "@/modules/school/data-access" import { getGradeHomeworkInsights } from "@/modules/classes/data-access" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -25,6 +27,7 @@ export default async function AdminGradeInsightsPage({ }: { searchParams: Promise }): Promise { + await requirePermission(Permissions.SCHOOL_MANAGE) const params = await searchParams const gradeId = getSearchParam(params, "gradeId") const selected = gradeId && gradeId !== "all" ? gradeId : "" diff --git a/src/app/(dashboard)/admin/school/grades/loading.tsx b/src/app/(dashboard)/admin/school/grades/loading.tsx new file mode 100644 index 0000000..0d6a4d6 --- /dev/null +++ b/src/app/(dashboard)/admin/school/grades/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminGradesLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/grades/page.tsx b/src/app/(dashboard)/admin/school/grades/page.tsx index dbc6cb7..5718900 100644 --- a/src/app/(dashboard)/admin/school/grades/page.tsx +++ b/src/app/(dashboard)/admin/school/grades/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { GradesClient } from "@/modules/school/components/grades-view" import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access" @@ -12,6 +14,7 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminGradesPage(): Promise { + await requirePermission(Permissions.SCHOOL_MANAGE) const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()]) return ( diff --git a/src/app/(dashboard)/admin/school/schools/error.tsx b/src/app/(dashboard)/admin/school/schools/error.tsx new file mode 100644 index 0000000..8209776 --- /dev/null +++ b/src/app/(dashboard)/admin/school/schools/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminSchoolsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/schools/loading.tsx b/src/app/(dashboard)/admin/school/schools/loading.tsx new file mode 100644 index 0000000..fd69745 --- /dev/null +++ b/src/app/(dashboard)/admin/school/schools/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminSchoolsLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/schools/page.tsx b/src/app/(dashboard)/admin/school/schools/page.tsx index a4b2617..1e48bd7 100644 --- a/src/app/(dashboard)/admin/school/schools/page.tsx +++ b/src/app/(dashboard)/admin/school/schools/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { SchoolsClient } from "@/modules/school/components/schools-view" import { getSchools } from "@/modules/school/data-access" @@ -12,6 +14,7 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" export default async function AdminSchoolsPage(): Promise { + await requirePermission(Permissions.SCHOOL_MANAGE) const schools = await getSchools() return (
diff --git a/src/app/(dashboard)/admin/users/import/error.tsx b/src/app/(dashboard)/admin/users/import/error.tsx new file mode 100644 index 0000000..b91a014 --- /dev/null +++ b/src/app/(dashboard)/admin/users/import/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminUsersImportError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/users/import/loading.tsx b/src/app/(dashboard)/admin/users/import/loading.tsx new file mode 100644 index 0000000..3889968 --- /dev/null +++ b/src/app/(dashboard)/admin/users/import/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminUsersImportLoading() { + return ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/users/import/page.tsx b/src/app/(dashboard)/admin/users/import/page.tsx index 94e8491..8628164 100644 --- a/src/app/(dashboard)/admin/users/import/page.tsx +++ b/src/app/(dashboard)/admin/users/import/page.tsx @@ -3,6 +3,8 @@ import type { JSX } from "react" import Link from "next/link" import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { @@ -22,7 +24,8 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic" -export default function UserImportPage(): JSX.Element { +export default async function UserImportPage(): Promise { + await requirePermission(Permissions.USER_MANAGE) return (
diff --git a/src/app/(dashboard)/announcements/[id]/page.tsx b/src/app/(dashboard)/announcements/[id]/page.tsx new file mode 100644 index 0000000..254453f --- /dev/null +++ b/src/app/(dashboard)/announcements/[id]/page.tsx @@ -0,0 +1,36 @@ +import { notFound } from "next/navigation" +import type { Metadata } from "next" +import type { JSX } from "react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getAnnouncementById } from "@/modules/announcements/data-access" +import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail" + +export const metadata: Metadata = { + title: "Announcement - Next_Edu", +} + +export const dynamic = "force-dynamic" + +export default async function AnnouncementDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const { id } = await params + await requirePermission(Permissions.ANNOUNCEMENT_READ) + + const announcement = await getAnnouncementById(id) + if (!announcement) notFound() + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/announcements/loading.tsx b/src/app/(dashboard)/announcements/loading.tsx new file mode 100644 index 0000000..e7236e3 --- /dev/null +++ b/src/app/(dashboard)/announcements/loading.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AnnouncementsLoading() { + return ( +
+
+ + +
+ +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + +
+ + +
+
+
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/announcements/page.tsx b/src/app/(dashboard)/announcements/page.tsx index f4402cb..af2cead 100644 --- a/src/app/(dashboard)/announcements/page.tsx +++ b/src/app/(dashboard)/announcements/page.tsx @@ -1,17 +1,88 @@ +import type { Metadata } from "next" + import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getAnnouncements } from "@/modules/announcements/data-access" import { AnnouncementList } from "@/modules/announcements/components/announcement-list" +import { + getStudentActiveClassId, + getStudentActiveGradeId, + getClassGradeId, +} from "@/modules/classes/data-access" export const dynamic = "force-dynamic" -export const metadata = { +export const metadata: Metadata = { title: "Announcements", } +/** + * 根据当前用户身份解析受众信息(gradeId / classId)。 + * - admin:返回 null(管理端可见所有公告) + * - student / teacher:使用首个 classId 并查询其 gradeId + * - grade_head / teaching_head:使用首个 gradeId + * - parent:使用首个孩子的活跃班级信息 + * - 其他:返回 null(仅显示 school 类型公告由 audience.gradeId/classId 均缺失时的兜底处理) + */ +async function resolveAudience(ctx: { + userId: string + dataScope: + | { type: "all" } + | { type: "owned"; userId: string } + | { type: "class_members"; classIds: string[] } + | { type: "grade_managed"; gradeIds: string[] } + | { type: "class_taught"; classIds: string[]; subjectIds?: string[] } + | { type: "children"; childrenIds: string[] } +}): Promise<{ gradeId?: string; classId?: string } | null> { + const { dataScope } = ctx + + if (dataScope.type === "all") return null + + if (dataScope.type === "grade_managed") { + const gradeId = dataScope.gradeIds[0] + return gradeId ? { gradeId } : null + } + + if (dataScope.type === "class_members" || dataScope.type === "class_taught") { + const classId = dataScope.classIds[0] + if (!classId) return null + const gradeId = await getClassGradeId(classId) + return { classId, gradeId: gradeId ?? undefined } + } + + if (dataScope.type === "children") { + const childId = dataScope.childrenIds[0] + if (!childId) return null + const [classId, gradeId] = await Promise.all([ + getStudentActiveClassId(childId), + getStudentActiveGradeId(childId), + ]) + return { + classId: classId ?? undefined, + gradeId: gradeId ?? undefined, + } + } + + // owned / 其他:尝试用当前 userId 查询(兼容 student 角色直接访问) + const [classId, gradeId] = await Promise.all([ + getStudentActiveClassId(ctx.userId), + getStudentActiveGradeId(ctx.userId), + ]) + if (!classId && !gradeId) return null + return { + classId: classId ?? undefined, + gradeId: gradeId ?? undefined, + } +} + export default async function AnnouncementsPage() { - await requirePermission(Permissions.ANNOUNCEMENT_READ) - const announcements = await getAnnouncements({ status: "published" }) + const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) + const audience = await resolveAudience(ctx) + + const announcements = await getAnnouncements({ + status: "published", + audience: audience ?? undefined, + }) return (
@@ -21,7 +92,10 @@ export default async function AnnouncementsPage() { Stay up to date with the latest school announcements.

- + `/announcements/${id}`} + />
) } diff --git a/src/app/(dashboard)/dashboard/error.tsx b/src/app/(dashboard)/dashboard/error.tsx new file mode 100644 index 0000000..a5dfbab --- /dev/null +++ b/src/app/(dashboard)/dashboard/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function DashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/dashboard/loading.tsx b/src/app/(dashboard)/dashboard/loading.tsx new file mode 100644 index 0000000..e93c717 --- /dev/null +++ b/src/app/(dashboard)/dashboard/loading.tsx @@ -0,0 +1,38 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function DashboardLoading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/messages/[id]/loading.tsx b/src/app/(dashboard)/messages/[id]/loading.tsx new file mode 100644 index 0000000..6cd7bf2 --- /dev/null +++ b/src/app/(dashboard)/messages/[id]/loading.tsx @@ -0,0 +1,43 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { Button } from "@/shared/components/ui/button" + +export default function MessageDetailLoading() { + return ( +
+
+
+ +
+ + +
+
+ + + + +
+ + +
+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + +
+ +
+ + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/messages/compose/loading.tsx b/src/app/(dashboard)/messages/compose/loading.tsx new file mode 100644 index 0000000..770f235 --- /dev/null +++ b/src/app/(dashboard)/messages/compose/loading.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ComposeLoading() { + return ( +
+
+
+ + +
+ + + + + + +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/messages/error.tsx b/src/app/(dashboard)/messages/error.tsx new file mode 100644 index 0000000..72affc2 --- /dev/null +++ b/src/app/(dashboard)/messages/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function MessagesError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/messages/loading.tsx b/src/app/(dashboard)/messages/loading.tsx new file mode 100644 index 0000000..c8090a7 --- /dev/null +++ b/src/app/(dashboard)/messages/loading.tsx @@ -0,0 +1,36 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function MessagesLoading() { + return ( +
+
+ + +
+ +
+
+ + +
+ + {Array.from({ length: 5 }).map((_, i) => ( + + +
+ + +
+ +
+ + + + +
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/dashboard/error.tsx b/src/app/(dashboard)/parent/dashboard/error.tsx new file mode 100644 index 0000000..fe47a04 --- /dev/null +++ b/src/app/(dashboard)/parent/dashboard/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/dashboard/loading.tsx b/src/app/(dashboard)/parent/dashboard/loading.tsx new file mode 100644 index 0000000..b666294 --- /dev/null +++ b/src/app/(dashboard)/parent/dashboard/loading.tsx @@ -0,0 +1,50 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+
+ + + +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/dashboard/page.tsx b/src/app/(dashboard)/parent/dashboard/page.tsx index ac81bd2..26c664c 100644 --- a/src/app/(dashboard)/parent/dashboard/page.tsx +++ b/src/app/(dashboard)/parent/dashboard/page.tsx @@ -6,6 +6,8 @@ import { Users } from "lucide-react" export const dynamic = "force-dynamic" +export const metadata = { title: "Dashboard - Next_Edu" } + export default async function ParentDashboardPage() { const ctx = await requireAuth() diff --git a/src/app/(dashboard)/profile/error.tsx b/src/app/(dashboard)/profile/error.tsx new file mode 100644 index 0000000..040a0dd --- /dev/null +++ b/src/app/(dashboard)/profile/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ProfileError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/profile/loading.tsx b/src/app/(dashboard)/profile/loading.tsx new file mode 100644 index 0000000..be303bc --- /dev/null +++ b/src/app/(dashboard)/profile/loading.tsx @@ -0,0 +1,36 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ProfileLoading() { + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + {Array.from({ length: 4 }).map((_, j) => ( + + ))} + + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 9200cab..933dfb0 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -9,6 +9,7 @@ import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card" import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access" import { getUserProfile } from "@/modules/users/data-access" +import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -129,6 +130,19 @@ export default async function ProfilePage() { } /> +
+ + {userProfile.image ? : null} + + {(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()} + + +
+
{userProfile.name ?? "-"}
+
{userProfile.email}
+
+
+
diff --git a/src/app/(dashboard)/settings/error.tsx b/src/app/(dashboard)/settings/error.tsx new file mode 100644 index 0000000..6f2161c --- /dev/null +++ b/src/app/(dashboard)/settings/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function SettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/settings/loading.tsx b/src/app/(dashboard)/settings/loading.tsx new file mode 100644 index 0000000..dda910a --- /dev/null +++ b/src/app/(dashboard)/settings/loading.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function SettingsLoading() { + return ( +
+
+
+ + +
+ +
+ +
+ + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
+
+ ) +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 517bd47..46c525a 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,9 +1,10 @@ import { redirect } from "next/navigation" import { requireAuth } from "@/shared/lib/auth-guard" -import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view" +import { SettingsView } from "@/modules/settings/components/settings-view" import { StudentSettingsView } from "@/modules/settings/components/student-settings-view" import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view" +import { ParentSettingsView } from "@/modules/settings/components/parent-settings-view" import { getUserProfile } from "@/modules/users/data-access" import { getNotificationPreferences } from "@/modules/notifications/preferences" @@ -25,10 +26,20 @@ export default async function SettingsPage() { const notificationPrefs = await getNotificationPreferences(userId) if (roles.includes("admin")) { - return + return ( + + ) } if (roles.includes("student")) { return } + if (roles.includes("parent")) { + return + } return } diff --git a/src/app/(dashboard)/settings/security/error.tsx b/src/app/(dashboard)/settings/security/error.tsx new file mode 100644 index 0000000..401c9d3 --- /dev/null +++ b/src/app/(dashboard)/settings/security/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function SecuritySettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/settings/security/loading.tsx b/src/app/(dashboard)/settings/security/loading.tsx new file mode 100644 index 0000000..59081bd --- /dev/null +++ b/src/app/(dashboard)/settings/security/loading.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function SecuritySettingsLoading() { + return ( +
+
+ + +
+ +
+ + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + +
+
+ ) +} diff --git a/src/app/(dashboard)/student/dashboard/page.tsx b/src/app/(dashboard)/student/dashboard/page.tsx index 03b54d0..ebbc8e0 100644 --- a/src/app/(dashboard)/student/dashboard/page.tsx +++ b/src/app/(dashboard)/student/dashboard/page.tsx @@ -8,6 +8,8 @@ import type { StudentHomeworkProgressStatus } from "@/modules/homework/types" export const dynamic = "force-dynamic" +export const metadata = { title: "Dashboard - Next_Edu" } + const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { // getDay() 返回 0(周日)-6(周六),转换为 1-7(周一为 1) const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const @@ -85,10 +87,6 @@ export default async function StudentDashboardPage() { return (
-
-

Dashboard

-

Welcome back, {student.name}.

-
void }) { + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/dashboard/loading.tsx b/src/app/(dashboard)/teacher/dashboard/loading.tsx new file mode 100644 index 0000000..12cdafb --- /dev/null +++ b/src/app/(dashboard)/teacher/dashboard/loading.tsx @@ -0,0 +1,38 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherDashboardLoading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/dashboard/page.tsx b/src/app/(dashboard)/teacher/dashboard/page.tsx index c59f2f1..4cb99bc 100644 --- a/src/app/(dashboard)/teacher/dashboard/page.tsx +++ b/src/app/(dashboard)/teacher/dashboard/page.tsx @@ -7,6 +7,8 @@ import { getAuthContext } from "@/shared/lib/auth-guard" export const dynamic = "force-dynamic" +export const metadata = { title: "Dashboard - Next_Edu" } + export default async function TeacherDashboardPage(): Promise { await getAuthContext() const teacherId = await getTeacherIdForMutations() diff --git a/src/modules/announcements/actions.ts b/src/modules/announcements/actions.ts index 7e51e53..331e60d 100644 --- a/src/modules/announcements/actions.ts +++ b/src/modules/announcements/actions.ts @@ -6,6 +6,13 @@ import { createId } from "@paralleldrive/cuid2" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" +import { sendBatchNotifications } from "@/modules/notifications" +import type { NotificationPayload } from "@/modules/notifications" +import { getAllUserIds, getUserIdsByGradeId } from "@/modules/users/data-access" +import { + getStudentIdsByClassId, + getTeacherIdsByClassIds, +} from "@/modules/classes/data-access" import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema" import { @@ -27,6 +34,60 @@ function handleActionError(e: unknown): ActionState { return { success: false, message: "Unexpected error" } } +/** + * 根据公告类型解析目标用户 ID 列表。 + * - school: 全校所有用户 + * - grade: 该年级下所有用户 + * - class: 该班级学生 + 任课教师 + 班主任 + */ +async function resolveTargetUserIds(announcement: Announcement): Promise { + if (announcement.type === "school") { + return getAllUserIds() + } + + if (announcement.type === "grade" && announcement.targetGradeId) { + return getUserIdsByGradeId(announcement.targetGradeId) + } + + if (announcement.type === "class" && announcement.targetClassId) { + const [studentIds, teacherIds] = await Promise.all([ + getStudentIdsByClassId(announcement.targetClassId), + getTeacherIdsByClassIds([announcement.targetClassId]), + ]) + return Array.from(new Set([...studentIds, ...teacherIds])) + } + + return [] +} + +/** + * 发布公告后向目标用户批量发送通知。 + * 通知发送失败不影响公告发布本身,仅记录日志。 + */ +async function notifyAnnouncementPublished(announcement: Announcement): Promise { + try { + const targetUserIds = await resolveTargetUserIds(announcement) + if (targetUserIds.length === 0) return + + const payloads: NotificationPayload[] = targetUserIds.map((userId) => ({ + userId, + title: `新公告:${announcement.title}`, + content: announcement.content.slice(0, 200), + type: "info", + actionUrl: `/announcements/${announcement.id}`, + metadata: { + announcementId: announcement.id, + announcementType: announcement.type, + }, + })) + + await sendBatchNotifications(payloads) + } catch (error) { + // 通知发送失败不阻塞公告发布流程,仅记录错误 + console.error("Failed to send announcement notifications:", error) + } +} + export async function createAnnouncementAction( prevState: ActionState | null, formData: FormData @@ -74,6 +135,14 @@ export async function createAnnouncementAction( publishedAt, }) + // 如果创建时直接发布,触发通知(失败不阻塞) + if (isPublished) { + const created = await getAnnouncementById(id) + if (created) { + await notifyAnnouncementPublished(created) + } + } + revalidatePath("/admin/announcements") revalidatePath("/announcements") @@ -114,6 +183,7 @@ export async function updateAnnouncementAction( const input = parsed.data const isPublished = input.status === "published" + const wasPublished = existing.status === "published" const publishedAt = isPublished ? existing.publishedAt ? new Date(existing.publishedAt) @@ -133,6 +203,14 @@ export async function updateAnnouncementAction( updatedAt: new Date(), }) + // 当公告从非发布状态变为发布状态时,触发通知(失败不阻塞) + if (isPublished && !wasPublished) { + const updated = await getAnnouncementById(id) + if (updated) { + await notifyAnnouncementPublished(updated) + } + } + revalidatePath("/admin/announcements") revalidatePath(`/admin/announcements/${id}`) revalidatePath("/announcements") @@ -173,6 +251,9 @@ export async function publishAnnouncementAction(id: string): Promise => c !== undefined) + conditions.push(or(...orClauses)) + } + const rows = await db .select({ id: announcements.id, diff --git a/src/modules/announcements/types.ts b/src/modules/announcements/types.ts index 68eb32f..dd5f52f 100644 --- a/src/modules/announcements/types.ts +++ b/src/modules/announcements/types.ts @@ -24,6 +24,17 @@ export type GetAnnouncementsParams = { type?: AnnouncementType page?: number pageSize?: number + /** + * 受众过滤(用户端使用):当提供时,仅返回对该受众可见的公告。 + * - school 类型公告:对所有受众可见 + * - grade 类型公告:仅当 targetGradeId 与 audience.gradeId 匹配时可见 + * - class 类型公告:仅当 targetClassId 与 audience.classId 匹配时可见 + * 未提供时(管理端)返回所有公告。 + */ + audience?: { + gradeId?: string + classId?: string + } } export interface AnnouncementInsertData { diff --git a/src/modules/classes/components/admin-classes-view.tsx b/src/modules/classes/components/admin-classes-view.tsx index 59442de..ef4b153 100644 --- a/src/modules/classes/components/admin-classes-view.tsx +++ b/src/modules/classes/components/admin-classes-view.tsx @@ -39,9 +39,13 @@ import { formatDate } from "@/shared/lib/utils" export function AdminClassesClient({ classes, teachers, + schools, + grades, }: { classes: AdminClassListItem[] teachers: TeacherOption[] + schools: { id: string; name: string }[] + grades: { id: string; name: string; school: { id: string; name: string } }[] }) { const router = useRouter() const [isWorking, setIsWorking] = useState(false) @@ -50,18 +54,34 @@ export function AdminClassesClient({ const [deleteItem, setDeleteItem] = useState(null) const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers]) + const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools]) const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId) + const [createSchoolId, setCreateSchoolId] = useState(defaultSchoolId) + const [createGradeId, setCreateGradeId] = useState("") const [editTeacherId, setEditTeacherId] = useState("") + const [editSchoolId, setEditSchoolId] = useState("") + const [editGradeId, setEditGradeId] = useState("") const [editSubjectTeachers, setEditSubjectTeachers] = useState>([]) + const createGrades = useMemo(() => grades.filter((g) => g.school.id === createSchoolId), [grades, createSchoolId]) + const editGrades = useMemo(() => grades.filter((g) => g.school.id === editSchoolId), [grades, editSchoolId]) + const selectedCreateSchool = schools.find((s) => s.id === createSchoolId) + const selectedCreateGrade = grades.find((g) => g.id === createGradeId) + const selectedEditSchool = schools.find((s) => s.id === editSchoolId) + const selectedEditGrade = grades.find((g) => g.id === editGradeId) + useEffect(() => { if (!createOpen) return setCreateTeacherId(defaultTeacherId) - }, [createOpen, defaultTeacherId]) + setCreateSchoolId(defaultSchoolId) + setCreateGradeId("") + }, [createOpen, defaultTeacherId, defaultSchoolId]) useEffect(() => { if (!editItem) return setEditTeacherId(editItem.teacher.id) + setEditSchoolId(editItem.schoolId ?? "") + setEditGradeId(editItem.gradeId ?? "") setEditSubjectTeachers( DEFAULT_CLASS_SUBJECTS.map((s) => ({ subject: s, @@ -227,10 +247,30 @@ export function AdminClassesClient({
- - + +
+ + + +
@@ -241,10 +281,27 @@ export function AdminClassesClient({
- - + +
+ + + +
@@ -284,7 +341,7 @@ export function AdminClassesClient({ - @@ -306,15 +363,30 @@ export function AdminClassesClient({ {editItem ? (
- - + +
+ + + +
@@ -325,10 +397,27 @@ export function AdminClassesClient({
- - + +
+ + + +
diff --git a/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx b/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx index 13f40d0..628520f 100644 --- a/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx @@ -1,11 +1,13 @@ -import { PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react" +import { BookOpen, CheckCircle, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react" import { StatCard } from "@/shared/components/ui/stat-card" import type { StudentRanking } from "@/modules/homework/types" export function StudentStatsGrid({ + enrolledClassCount, dueSoonCount, overdueCount, + gradedCount, ranking, }: { enrolledClassCount: number @@ -16,12 +18,21 @@ export function StudentStatsGrid({ }) { return (
+ @@ -30,10 +41,19 @@ export function StudentStatsGrid({ value={ranking ? `${ranking.rank}/${ranking.classSize}` : "-"} description={ranking ? "Current position" : "No ranking yet"} icon={Trophy} - href="/student/learning/assignments" + href="/student/grades" color="text-purple-500" valueClassName={ranking ? "text-purple-500 tabular-nums" : "tabular-nums"} /> + { + const [h, m] = t.split(":").map(Number) + return (h ?? 0) * 60 + (m ?? 0) +} + export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) { const hasSchedule = items.length > 0 + // Compute current/next class status based on client time + const { currentId, nextId } = useMemo(() => { + const now = new Date() + const nowMin = now.getHours() * 60 + now.getMinutes() + let currentId: string | null = null + let nextId: string | null = null + for (const item of items) { + const start = timeToMinutes(item.startTime) + const end = timeToMinutes(item.endTime) + if (nowMin >= start && nowMin < end) { + currentId = item.id + break + } + if (nowMin < start) { + nextId = item.id + break + } + } + return { currentId, nextId } + }, [items]) + return ( - - + + Today's Schedule + {!hasSchedule ? ( @@ -29,6 +68,30 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul items={items} variant="separator" spacingClassName="space-y-4" + renderTrailing={(item) => { + const isCurrent = item.id === currentId + const isNext = item.id === nextId + if (isCurrent) { + return ( + + In Progress + + ) + } + if (isNext) { + return ( + + Up Next + + ) + } + return item.className ? ( + + {item.className} + + ) : null + }} + className={cn(currentId && "[&_div:first-child]:bg-emerald-50/50")} /> )} diff --git a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx index 37b5c0c..3f6beb7 100644 --- a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx @@ -5,23 +5,14 @@ import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" +import { StatusBadge } from "@/shared/components/ui/status-badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { formatDate, cn } from "@/shared/lib/utils" import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types" - -const getStatusVariant = (status: string): "default" | "secondary" | "outline" => { - if (status === "graded") return "default" - if (status === "submitted") return "secondary" - if (status === "in_progress") return "secondary" - return "outline" -} - -const getStatusLabel = (status: string) => { - if (status === "graded") return "Graded" - if (status === "submitted") return "Submitted" - if (status === "in_progress") return "In progress" - return "Not started" -} +import { + STUDENT_HOMEWORK_PROGRESS_VARIANT, + STUDENT_HOMEWORK_PROGRESS_LABEL, +} from "@/modules/homework/types" const getActionLabel = (status: string) => { if (status === "graded") return "Review" @@ -51,7 +42,7 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi const hasAssignments = upcomingAssignments.length > 0 return ( - + @@ -99,9 +90,11 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
- - {getStatusLabel(a.progressStatus)} - +
-

Good morning, {teacherName}

-

It's {today}. Here's your daily overview.

+

{greeting},{teacherName}

+

今天是 {today},以下是今日概览。

diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx index d2341ef..c5455b3 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -7,6 +7,7 @@ import { RecentSubmissions } from "./recent-submissions" import { TeacherSchedule } from "./teacher-schedule" import { TeacherStats } from "./teacher-stats" import { TeacherGradeTrends } from "./teacher-grade-trends" +import { TeacherTodoCard, type TeacherTodoItem } from "./teacher-todo-card" const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { const day = d.getDay() @@ -32,16 +33,12 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) { const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt)) const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length - - // Filter for submissions that actually need grading (status === "submitted") - // If we have less than 5 to grade, maybe also show some recently graded ones? - // For now, let's stick to "Needs Grading" as it's more useful. + const submissionsToGrade = submittedSubmissions .filter(s => s.status === "submitted") - .sort((a, b) => new Date(a.submittedAt!).getTime() - new Date(b.submittedAt!).getTime()) // Oldest first? Or Newest? Usually oldest first for queue. + .sort((a, b) => (a.submittedAt ? new Date(a.submittedAt).getTime() : 0) - (b.submittedAt ? new Date(b.submittedAt).getTime() : 0)) .slice(0, 6); - // Calculate stats for the dashboard const activeAssignmentsCount = data.assignments.filter(a => a.status === "published").length const totalTrendScore = data.gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0) @@ -51,6 +48,13 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) { const totalPotentialSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0) const submissionRate = totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0 + // 待办聚合 + const todoItems: TeacherTodoItem[] = [ + { label: "待批改作业", count: toGradeCount, href: "/teacher/homework/submissions", variant: toGradeCount > 0 ? "urgent" : "normal" }, + { label: "今日待考勤", count: todayScheduleItems.length, href: "/teacher/attendance/sheet", variant: "info" }, + { label: "进行中作业", count: activeAssignmentsCount, href: "/teacher/homework/assignments", variant: "normal" }, + ] + return (
@@ -63,18 +67,25 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) { />
+ {/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
+
+ +
+
- +
+ +
diff --git a/src/modules/messaging/actions.ts b/src/modules/messaging/actions.ts index 03f8c5d..47ccad3 100644 --- a/src/modules/messaging/actions.ts +++ b/src/modules/messaging/actions.ts @@ -18,11 +18,13 @@ import { markMessageAsRead, deleteMessage, getRecipients, + getUnreadMessageCount, } from "./data-access" import { getNotifications, markNotificationAsRead, markAllNotificationsAsRead, + getUnreadNotificationCount, } from "@/modules/notifications/data-access" import { getNotificationPreferences, @@ -129,7 +131,7 @@ export async function deleteMessageAction(messageId: string): Promise> { try { const ctx = await requirePermission(Permissions.MESSAGE_READ) @@ -179,6 +181,30 @@ export async function getRecipientsAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + const count = await getUnreadMessageCount(ctx.userId) + return { success: true, data: count } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function getUnreadNotificationCountAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + const count = await getUnreadNotificationCount(ctx.userId) + return { success: true, data: count } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + export async function getNotificationsAction( params?: { page?: number; pageSize?: number; unreadOnly?: boolean } ): Promise> { @@ -242,6 +268,13 @@ export async function updateNotificationPreferencesAction( // 从 FormData 中解析布尔值(checkbox 提交 "on" 或不提交) const parseBool = (key: string): boolean => formData.get(key) === "on" + // 从 FormData 中解析时间字符串("HH:mm"),空字符串转为 null + const parseTime = (key: string): string | null => { + const v = formData.get(key) + if (typeof v !== "string") return null + const trimmed = v.trim() + return trimmed.length > 0 ? trimmed : null + } const parsed = UpdateNotificationPreferencesSchema.safeParse({ emailEnabled: parseBool("emailEnabled"), @@ -252,6 +285,9 @@ export async function updateNotificationPreferencesAction( announcementNotifications: parseBool("announcementNotifications"), messageNotifications: parseBool("messageNotifications"), attendanceNotifications: parseBool("attendanceNotifications"), + quietHoursEnabled: parseBool("quietHoursEnabled"), + quietHoursStart: parseTime("quietHoursStart"), + quietHoursEnd: parseTime("quietHoursEnd"), }) if (!parsed.success) { diff --git a/src/modules/messaging/components/message-list.tsx b/src/modules/messaging/components/message-list.tsx index 22622c8..138de4b 100644 --- a/src/modules/messaging/components/message-list.tsx +++ b/src/modules/messaging/components/message-list.tsx @@ -1,18 +1,20 @@ "use client" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { Mail, MailOpen, Plus, Send, Inbox } from "lucide-react" +import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2 } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" +import { Input } from "@/shared/components/ui/input" import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { cn, formatDate } from "@/shared/lib/utils" import { usePermission } from "@/shared/hooks/use-permission" import { Permissions } from "@/shared/types/permissions" +import { getMessagesAction } from "../actions" import type { Message, MessageType } from "../types" type Tab = "inbox" | "sent" @@ -27,13 +29,49 @@ export function MessageList({ initialType?: MessageType }) { const [tab, setTab] = useState(initialType === "sent" ? "sent" : "inbox") + const [keyword, setKeyword] = useState("") + const [searchResults, setSearchResults] = useState<{ kw: string; tab: Tab; items: Message[] } | null>(null) const { hasPermission } = usePermission() const canSend = hasPermission(Permissions.MESSAGE_SEND) + // 防抖搜索:keyword 或 tab 变化时调用 getMessagesAction + useEffect(() => { + const kw = keyword.trim() + if (kw.length === 0) { + return + } + + let cancelled = false + const timer = setTimeout(async () => { + if (cancelled) return + const res = await getMessagesAction({ type: tab, keyword: kw }) + if (cancelled) return + if (res.success && res.data) { + setSearchResults({ kw, tab, items: res.data.items }) + } + }, 400) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [keyword, tab]) + + // 当前搜索结果是否匹配最新的 keyword 和 tab + const currentResults = searchResults && searchResults.kw === keyword.trim() && searchResults.tab === tab + ? searchResults.items + : null + + // 搜索中:keyword 非空且尚无匹配结果 + const searching = keyword.trim().length > 0 && currentResults === null + + // 当 keyword 为空时使用 prop messages,否则使用搜索结果 + const displayMessages = currentResults ?? messages + const filtered = useMemo(() => { - if (tab === "inbox") return messages.filter((m) => m.receiverId === currentUserId) - return messages.filter((m) => m.senderId === currentUserId) - }, [messages, tab, currentUserId]) + if (tab === "inbox") return displayMessages.filter((m) => m.receiverId === currentUserId) + return displayMessages.filter((m) => m.senderId === currentUserId) + }, [displayMessages, tab, currentUserId]) return (
@@ -60,6 +98,21 @@ export function MessageList({ ) : null}
+ {/* 搜索框 */} +
+ + setKeyword(e.target.value)} + className="pl-9" + /> + {searching ? ( + + ) : null} +
+ {filtered.length === 0 ? ( { let active = true - void (async () => { + + const fetchNotifications = async () => { const res = await getNotificationsAction({ pageSize: 10 }) if (!active) return if (res.success && res.data) { setNotifications(res.data.items) - setUnreadCount(res.data.items.filter((n) => !n.isRead).length) } - })() + } + + const fetchUnreadCount = async () => { + const res = await getUnreadNotificationCountAction() + if (!active) return + if (res.success && typeof res.data === "number") { + setUnreadCount(res.data) + } + } + + void fetchNotifications() + void fetchUnreadCount() + + // 每 30 秒轮询刷新通知和未读计数 + const timer = setInterval(() => { + void fetchNotifications() + void fetchUnreadCount() + }, 30_000) + return () => { active = false + clearInterval(timer) } }, []) diff --git a/src/modules/messaging/components/unread-message-badge.tsx b/src/modules/messaging/components/unread-message-badge.tsx new file mode 100644 index 0000000..13f74a5 --- /dev/null +++ b/src/modules/messaging/components/unread-message-badge.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useEffect, useState } from "react" + +import { Badge } from "@/shared/components/ui/badge" + +import { getUnreadMessageCountAction } from "../actions" + +/** + * 未读消息计数徽章 + * + * 在侧边栏 Messages 导航项旁显示未读私信数。 + * 每 60 秒轮询一次以保持计数更新。 + */ +export function UnreadMessageBadge() { + const [count, setCount] = useState(0) + + useEffect(() => { + let active = true + + const fetchCount = async () => { + const res = await getUnreadMessageCountAction() + if (!active) return + if (res.success && typeof res.data === "number") { + setCount(res.data) + } + } + + void fetchCount() + + const timer = setInterval(() => { + void fetchCount() + }, 60_000) + + return () => { + active = false + clearInterval(timer) + } + }, []) + + if (count <= 0) return null + + return ( + + {count > 99 ? "99+" : count} + + ) +} diff --git a/src/modules/messaging/data-access.ts b/src/modules/messaging/data-access.ts index c46bddb..9aeb887 100644 --- a/src/modules/messaging/data-access.ts +++ b/src/modules/messaging/data-access.ts @@ -16,7 +16,7 @@ import "server-only" import { cache } from "react" import { createId } from "@paralleldrive/cuid2" -import { and, count, desc, eq, inArray, or, type SQL } from "drizzle-orm" +import { and, count, desc, eq, inArray, isNull, like, or, type SQL } from "drizzle-orm" import { db } from "@/shared/db" import { @@ -86,13 +86,28 @@ export const getMessages = cache( const offset = (page - 1) * pageSize const conds: SQL[] = [] - if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId)) - else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId)) - else { - const cond = or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId)) + if (params.type === "inbox") { + conds.push(eq(messages.receiverId, params.userId)) + conds.push(isNull(messages.receiverDeletedAt)) + } else if (params.type === "sent") { + conds.push(eq(messages.senderId, params.userId)) + conds.push(isNull(messages.senderDeletedAt)) + } else { + // all: 仅返回当前用户未删除的消息(发送方未删 或 接收方未删) + const cond = or( + and(eq(messages.receiverId, params.userId), isNull(messages.receiverDeletedAt)), + and(eq(messages.senderId, params.userId), isNull(messages.senderDeletedAt)) + ) if (cond) conds.push(cond) } + // 关键词搜索(匹配 subject 或 content) + if (params.keyword && params.keyword.trim().length > 0) { + const kw = `%${params.keyword.trim()}%` + const kwCond = or(like(messages.subject, kw), like(messages.content, kw)) + if (kwCond) conds.push(kwCond) + } + const where = and(...conds) const [rows, [totalRow]] = await Promise.all([ db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset), @@ -111,7 +126,15 @@ export const getMessageById = cache( const [row] = await db .select() .from(messages) - .where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId)))) + .where( + and( + eq(messages.id, id), + or( + and(eq(messages.senderId, userId), isNull(messages.senderDeletedAt)), + and(eq(messages.receiverId, userId), isNull(messages.receiverDeletedAt)) + ) + ) + ) .limit(1) if (!row) return null const nameMap = await resolveUserNames([row.senderId, row.receiverId]) @@ -155,16 +178,23 @@ export async function markMessageAsRead(id: string, userId: string): Promise { + const now = new Date() + // 软删除:发送方删除设置 senderDeletedAt,接收方删除设置 receiverDeletedAt,互不影响 await db - .delete(messages) - .where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId)))) + .update(messages) + .set({ senderDeletedAt: now }) + .where(and(eq(messages.id, id), eq(messages.senderId, userId))) + await db + .update(messages) + .set({ receiverDeletedAt: now }) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) } export const getUnreadMessageCount = cache(async (userId: string): Promise => { const [row] = await db .select({ value: count() }) .from(messages) - .where(and(eq(messages.receiverId, userId), eq(messages.isRead, false))) + .where(and(eq(messages.receiverId, userId), eq(messages.isRead, false), isNull(messages.receiverDeletedAt))) return Number(row?.value ?? 0) }) diff --git a/src/modules/messaging/schema.ts b/src/modules/messaging/schema.ts index 0174dff..2c5ee5a 100644 --- a/src/modules/messaging/schema.ts +++ b/src/modules/messaging/schema.ts @@ -24,7 +24,7 @@ export const MessageIdSchema = z.object({ export type MessageIdInput = z.infer -/** 校验通知偏好更新表单(8 个布尔字段,来自 checkbox FormData) */ +/** 校验通知偏好更新表单(8 个布尔字段 + 免打扰时段,来自 checkbox/FormData) */ export const UpdateNotificationPreferencesSchema = z.object({ emailEnabled: z.boolean(), smsEnabled: z.boolean(), @@ -34,6 +34,9 @@ export const UpdateNotificationPreferencesSchema = z.object({ announcementNotifications: z.boolean(), messageNotifications: z.boolean(), attendanceNotifications: z.boolean(), + quietHoursEnabled: z.boolean(), + quietHoursStart: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(), + quietHoursEnd: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(), }) export type UpdateNotificationPreferencesFormInput = z.infer< diff --git a/src/modules/messaging/types.ts b/src/modules/messaging/types.ts index b0e8bfa..f5f0bad 100644 --- a/src/modules/messaging/types.ts +++ b/src/modules/messaging/types.ts @@ -33,6 +33,7 @@ export interface GetMessagesParams { type: MessageType page?: number pageSize?: number + keyword?: string } export interface CreateMessageInput { diff --git a/src/modules/notifications/preferences.ts b/src/modules/notifications/preferences.ts index e968606..a544728 100644 --- a/src/modules/notifications/preferences.ts +++ b/src/modules/notifications/preferences.ts @@ -39,6 +39,9 @@ const mapRow = ( announcementNotifications: row.announcementNotifications, messageNotifications: row.messageNotifications, attendanceNotifications: row.attendanceNotifications, + quietHoursEnabled: row.quietHoursEnabled, + quietHoursStart: row.quietHoursStart, + quietHoursEnd: row.quietHoursEnd, createdAt: toIso(row.createdAt), updatedAt: toIso(row.updatedAt), }) @@ -53,6 +56,9 @@ const DEFAULTS = { announcementNotifications: true, messageNotifications: true, attendanceNotifications: true, + quietHoursEnabled: false, + quietHoursStart: null, + quietHoursEnd: null, } /** @@ -133,6 +139,9 @@ export async function upsertNotificationPreferences( if (input.announcementNotifications !== undefined) updateData.announcementNotifications = input.announcementNotifications if (input.messageNotifications !== undefined) updateData.messageNotifications = input.messageNotifications if (input.attendanceNotifications !== undefined) updateData.attendanceNotifications = input.attendanceNotifications + if (input.quietHoursEnabled !== undefined) updateData.quietHoursEnabled = input.quietHoursEnabled + if (input.quietHoursStart !== undefined) updateData.quietHoursStart = input.quietHoursStart + if (input.quietHoursEnd !== undefined) updateData.quietHoursEnd = input.quietHoursEnd if (Object.keys(updateData).length === 0) { return mapRow(existing) @@ -165,6 +174,9 @@ export async function upsertNotificationPreferences( announcementNotifications: input.announcementNotifications ?? DEFAULTS.announcementNotifications, messageNotifications: input.messageNotifications ?? DEFAULTS.messageNotifications, attendanceNotifications: input.attendanceNotifications ?? DEFAULTS.attendanceNotifications, + quietHoursEnabled: input.quietHoursEnabled ?? DEFAULTS.quietHoursEnabled, + quietHoursStart: input.quietHoursStart ?? DEFAULTS.quietHoursStart, + quietHoursEnd: input.quietHoursEnd ?? DEFAULTS.quietHoursEnd, }) const [created] = await db diff --git a/src/modules/notifications/types.ts b/src/modules/notifications/types.ts index 97764ba..6bd9927 100644 --- a/src/modules/notifications/types.ts +++ b/src/modules/notifications/types.ts @@ -95,6 +95,9 @@ export interface NotificationPreferences { announcementNotifications: boolean messageNotifications: boolean attendanceNotifications: boolean + quietHoursEnabled: boolean + quietHoursStart: string | null + quietHoursEnd: string | null createdAt: string updatedAt: string } @@ -109,6 +112,9 @@ export interface UpdateNotificationPreferencesInput { announcementNotifications?: boolean messageNotifications?: boolean attendanceNotifications?: boolean + quietHoursEnabled?: boolean + quietHoursStart?: string | null + quietHoursEnd?: string | null } /** SMS 渠道配置 */ diff --git a/src/modules/settings/components/notification-preferences-form.tsx b/src/modules/settings/components/notification-preferences-form.tsx index d9503e3..167acac 100644 --- a/src/modules/settings/components/notification-preferences-form.tsx +++ b/src/modules/settings/components/notification-preferences-form.tsx @@ -3,14 +3,16 @@ import * as React from "react" import { useActionState } from "react" import { useFormStatus } from "react-dom" -import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck } from "lucide-react" +import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react" import { toast } from "sonner" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" import { Switch } from "@/shared/components/ui/switch" import { Label } from "@/shared/components/ui/label" import { Separator } from "@/shared/components/ui/separator" +import { cn } from "@/shared/lib/utils" import { updateNotificationPreferencesAction } from "@/modules/messaging/actions" import type { NotificationPreferences } from "@/modules/notifications/types" @@ -131,6 +133,11 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere messageNotifications: preferences.messageNotifications, attendanceNotifications: preferences.attendanceNotifications, }) + const [quietHours, setQuietHours] = React.useState({ + quietHoursEnabled: preferences.quietHoursEnabled, + quietHoursStart: preferences.quietHoursStart ?? "", + quietHoursEnd: preferences.quietHoursEnd ?? "", + }) React.useEffect(() => { if (state?.success) { @@ -148,6 +155,10 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere setCategories((prev) => ({ ...prev, [key]: !prev[key] })) } + const toggleQuietHours = () => { + setQuietHours((prev) => ({ ...prev, quietHoursEnabled: !prev.quietHoursEnabled })) + } + return ( @@ -250,6 +261,80 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere ) })}
+ + + + {/* 免打扰时段 */} +
+
+

Quiet Hours

+

+ Suppress non-urgent notifications during a specified time period each day. +

+
+
+
+
+ +
+
+ +

+ When enabled, only urgent notifications will be delivered during the specified hours. +

+
+
+
+ + +
+
+
+
+ + setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))} + disabled={!quietHours.quietHoursEnabled} + /> +
+
+ + setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))} + disabled={!quietHours.quietHoursEnabled} + /> +
+
+
diff --git a/src/modules/settings/components/parent-settings-view.tsx b/src/modules/settings/components/parent-settings-view.tsx new file mode 100644 index 0000000..9d32e3d --- /dev/null +++ b/src/modules/settings/components/parent-settings-view.tsx @@ -0,0 +1,65 @@ +"use client" + +import Link from "next/link" +import { LayoutDashboard, GraduationCap, CalendarDays, ClipboardList } from "lucide-react" + +import { SettingsView } from "@/modules/settings/components/settings-view" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { UserProfile } from "@/modules/users/data-access" +import type { NotificationPreferences } from "@/modules/notifications/types" + +interface ParentSettingsViewProps { + user: UserProfile + notificationPreferences: NotificationPreferences +} + +export function ParentSettingsView({ user, notificationPreferences }: ParentSettingsViewProps) { + const generalExtra = ( + + + Quick links + Common places you may want to visit. + + + + + + + + + + ) + + return ( + + ) +} diff --git a/src/modules/settings/components/password-change-form.tsx b/src/modules/settings/components/password-change-form.tsx index 7cb4d76..c983c4d 100644 --- a/src/modules/settings/components/password-change-form.tsx +++ b/src/modules/settings/components/password-change-form.tsx @@ -18,10 +18,10 @@ import { } from "@/shared/lib/password-policy" import type { ActionState } from "@/shared/types/action-state" -const STRENGTH_META: Record = { - weak: { value: 33, label: "Weak", barClass: "h-2 [&>div]:bg-red-500" }, - medium: { value: 66, label: "Medium", barClass: "h-2 [&>div]:bg-yellow-500" }, - strong: { value: 100, label: "Strong", barClass: "h-2 [&>div]:bg-green-500" }, +const STRENGTH_META: Record = { + weak: { value: 33, label: "Weak", barClassName: "h-2", indicatorClassName: "bg-red-500" }, + medium: { value: 66, label: "Medium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" }, + strong: { value: 100, label: "Strong", barClassName: "h-2", indicatorClassName: "bg-green-500" }, } function SubmitButton() { @@ -130,7 +130,7 @@ export function PasswordChangeForm() { Password strength {meta.label}
- +
)}
diff --git a/src/modules/settings/components/settings-view.tsx b/src/modules/settings/components/settings-view.tsx index 4a87a50..5b9ea84 100644 --- a/src/modules/settings/components/settings-view.tsx +++ b/src/modules/settings/components/settings-view.tsx @@ -1,19 +1,34 @@ "use client" import Link from "next/link" -import type { ReactNode } from "react" -import { User, Palette, Lock, Bell } from "lucide-react" +import { useRouter, useSearchParams } from "next/navigation" +import { Suspense, type ReactNode } from "react" +import { User, Palette, Lock, Bell, Sparkles } from "lucide-react" import { signOut } from "next-auth/react" import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card" import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form" import { PasswordChangeForm } from "@/modules/settings/components/password-change-form" import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form" +import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/components/ui/alert-dialog" import { UserProfile } from "@/modules/users/data-access" import type { NotificationPreferences } from "@/modules/notifications/types" +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" interface SettingsViewProps { /** 页面副标题描述 */ @@ -28,24 +43,52 @@ interface SettingsViewProps { generalExtra?: ReactNode } +const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const +type TabValue = (typeof VALID_TABS)[number] + +function isTabValue(value: string | null): value is TabValue { + return value !== null && (VALID_TABS as readonly string[]).includes(value) +} + /** * 统一设置页视图 * - * 消除 admin / teacher / student 三个设置视图的重复布局: + * 消除 admin / teacher / student / parent 四个设置视图的重复布局: * - 相同的页面头部(标题 + 描述 + 返回按钮) - * - 相同的 4 个标签页(General / Notifications / Appearance / Security) + * - 相同的标签页(General / Notifications / Appearance / Security / AI) * - 相同的 Notifications / Appearance / Security 标签页内容 * - 相同的 Session 卡片(登出) * * 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。 + * 当前激活的标签页通过 URL `?tab=` 参数持久化。 */ -export function SettingsView({ +function SettingsViewInner({ description, backHref, user, notificationPreferences, generalExtra, }: SettingsViewProps) { + const router = useRouter() + const searchParams = useSearchParams() + const { hasPermission } = usePermission() + + const tabParam = searchParams.get("tab") + const activeTab: TabValue = isTabValue(tabParam) ? tabParam : "general" + + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()) + if (value === "general") { + params.delete("tab") + } else { + params.set("tab", value) + } + const query = params.toString() + router.push(query ? `?${query}` : "?", { scroll: false }) + } + + const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE) + return (
@@ -60,7 +103,7 @@ export function SettingsView({
- + @@ -78,6 +121,12 @@ export function SettingsView({ Security + {canConfigureAi ? ( + + + AI + + ) : null} @@ -105,13 +154,43 @@ export function SettingsView({
Sign out
Return to the login screen.
- + + + + + + + Confirm sign out + + Are you sure you want to sign out? You will be returned to the login screen. + + + + Cancel + signOut({ callbackUrl: "/login" })}> + Sign out + + + + + + {canConfigureAi ? ( + + + + ) : null}
) } + +export function SettingsView(props: SettingsViewProps) { + return ( + + + + ) +} diff --git a/src/shared/components/ui/progress.tsx b/src/shared/components/ui/progress.tsx index 06986d4..03ed15e 100644 --- a/src/shared/components/ui/progress.tsx +++ b/src/shared/components/ui/progress.tsx @@ -7,8 +7,11 @@ import { cn } from "@/shared/lib/utils" const Progress = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** Optional className applied to the inner indicator element. */ + indicatorClassName?: string + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts index 2ca8a0a..8fb33a2 100644 --- a/src/shared/db/schema.ts +++ b/src/shared/db/schema.ts @@ -6,6 +6,7 @@ import { int, primaryKey, index, + uniqueIndex, json, mysqlEnum, boolean, @@ -377,6 +378,52 @@ export const classSubjectTeachers = mysqlTable("class_subject_teachers", { }).onDelete("cascade"), })); +/** + * 班级邀请码表(v3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。 + * + * 设计要点: + * - 独立表而非挂在 classes 表上,支持有效期/次数/审计/多码并存 + * - 6 位字母数字(剔除歧义字符 0/O/1/I/L),空间 22^6 ≈ 1.13 亿 + * - status 枚举:active/disabled/expired/exhausted + * - max_uses NULL=无限;expires_at NULL=永久 + * - 软删除:revoke 时设置 status=disabled + revoked_at,不物理删除(审计需要) + * + * 与 classes.invitationCode 的关系: + * - 新表上线后,enrollStudentByInvitationCode / enrollTeacherByInvitationCode 优先查新表 + * - classes.invitationCode 保留作为 fallback,下个版本移除 + */ +export const classInvitationCodeStatusEnum = mysqlEnum("class_invitation_code_status", [ + "active", + "disabled", + "expired", + "exhausted", +]); + +export const classInvitationCodes = mysqlTable("class_invitation_codes", { + id: id("id").primaryKey(), + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + code: varchar("code", { length: 8 }).notNull().unique(), + status: classInvitationCodeStatusEnum.default("active").notNull(), + maxUses: int("max_uses"), + usedCount: int("used_count").default(0).notNull(), + expiresAt: timestamp("expires_at"), + createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + revokedAt: timestamp("revoked_at"), + revokedBy: varchar("revoked_by", { length: 128 }), + note: varchar("note", { length: 255 }), +}, (table) => ({ + codeIdx: uniqueIndex("class_invitation_codes_code_idx").on(table.code), + classIdx: index("class_invitation_codes_class_idx").on(table.classId), + statusExpiresIdx: index("class_invitation_codes_status_expires_idx").on(table.status, table.expiresAt), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "cic_c_fk", + }).onDelete("cascade"), +})); + export const classEnrollments = mysqlTable("class_enrollments", { classId: varchar("class_id", { length: 128 }).notNull(), studentId: varchar("student_id", { length: 128 }).notNull(), @@ -514,7 +561,7 @@ export const submissionAnswers = mysqlTable("submission_answers", { export const homeworkAssignments = mysqlTable("homework_assignments", { id: id("id").primaryKey(), - sourceExamId: varchar("source_exam_id", { length: 128 }).notNull(), + sourceExamId: varchar("source_exam_id", { length: 128 }), title: varchar("title", { length: 255 }).notNull(), description: text("description"), structure: json("structure"), @@ -904,6 +951,9 @@ export const messages = mysqlTable("messages", { isRead: boolean("is_read").default(false).notNull(), readAt: timestamp("read_at", { mode: "date" }), parentMessageId: varchar("parent_message_id", { length: 128 }), // 回复链 + // 软删除:发送方/接收方各自独立删除,互不影响 + senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }), + receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), }, (table) => ({ senderIdx: index("messages_sender_idx").on(table.senderId), @@ -944,6 +994,10 @@ export const notificationPreferences = mysqlTable("notification_preferences", { announcementNotifications: boolean("announcement_notifications").default(true).notNull(), messageNotifications: boolean("message_notifications").default(true).notNull(), attendanceNotifications: boolean("attendance_notifications").default(true).notNull(), + // 免打扰时段(格式 "HH:mm",如 "22:00") + quietHoursEnabled: boolean("quiet_hours_enabled").default(false).notNull(), + quietHoursStart: varchar("quiet_hours_start", { length: 5 }), + quietHoursEnd: varchar("quiet_hours_end", { length: 5 }), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), }, (table) => ({