feat(dashboard): 仪表盘模块审计重构 — 权限校验 + i18n + 逻辑抽离
基于 dashboard-audit-report.md 审计结论,对仪表盘模块进行 P0/P1 级修复:
- 新增 4 个 dashboard 权限点(DASHBOARD_ADMIN/TEACHER/STUDENT/PARENT_READ),补充到 permissions.ts 和角色-权限映射
- 新建 actions.ts:4 个 Server Action 均调用 requirePermission() 校验权限,消除 admin 页面零鉴权、teacher/student/parent 仅 requireAuth 的安全隐患
- 根重定向页 /dashboard 改用 resolvePermissions() + 权限点判断,不再 role === xxx 硬编码
- 新建 lib/dashboard-utils.ts:抽取 toWeekday / countStudentAssignments / sortUpcomingAssignments / filterTodaySchedule / computeTeacherMetrics / getGreetingKey 纯函数,与 UI 分离,便于单测
- 新建 messages/{zh-CN,en}/dashboard.json 翻译文件,i18n request.ts 加载 dashboard 命名空间;所有视图组件接入 useTranslations / getTranslations,消除中英混杂硬编码
- 重构 4 个角色 page.tsx:通过 actions 获取数据,generateMetadata 使用 i18n
- 同步更新架构图 004 / 005 文档(dashboard exports / permissions / 文件清单)
This commit is contained in:
146
src/modules/dashboard/actions.ts
Normal file
146
src/modules/dashboard/actions.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
"use server"
|
||||
|
||||
import { cache } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import {
|
||||
getHomeworkAssignments,
|
||||
getHomeworkSubmissions,
|
||||
getStudentDashboardGrades,
|
||||
getStudentHomeworkAssignments,
|
||||
getTeacherGradeTrends,
|
||||
} from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser, getUserBasicInfo } from "@/modules/users/data-access"
|
||||
import { getParentDashboardData } from "@/modules/parent/data-access"
|
||||
|
||||
import { getAdminDashboardData } from "./data-access"
|
||||
import type {
|
||||
AdminDashboardData,
|
||||
StudentDashboardProps,
|
||||
TeacherDashboardData,
|
||||
} from "./types"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
import {
|
||||
computeTeacherMetrics,
|
||||
countStudentAssignments,
|
||||
sortUpcomingAssignments,
|
||||
toWeekday,
|
||||
filterTodaySchedule,
|
||||
type TeacherDashboardMetrics,
|
||||
} from "./lib/dashboard-utils"
|
||||
|
||||
/**
|
||||
* 获取管理员仪表盘数据。
|
||||
* 权限:DASHBOARD_ADMIN_READ
|
||||
*/
|
||||
export async function getAdminDashboardAction(): Promise<AdminDashboardData> {
|
||||
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
||||
return getAdminDashboardData(ctx.dataScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师仪表盘数据(含派生指标)。
|
||||
* 权限:DASHBOARD_TEACHER_READ
|
||||
*/
|
||||
export async function getTeacherDashboardAction(): Promise<TeacherDashboardData & {
|
||||
metrics: TeacherDashboardMetrics
|
||||
}> {
|
||||
await requirePermission(Permissions.DASHBOARD_TEACHER_READ)
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
||||
getTeacherClasses({ teacherId }),
|
||||
getClassSchedule({ teacherId }),
|
||||
getHomeworkAssignments({ creatorId: teacherId }),
|
||||
getHomeworkSubmissions({ creatorId: teacherId }),
|
||||
getUserBasicInfo(teacherId),
|
||||
getTeacherGradeTrends(teacherId),
|
||||
])
|
||||
|
||||
const metrics = computeTeacherMetrics(
|
||||
classes,
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
gradeTrends,
|
||||
new Date(),
|
||||
)
|
||||
|
||||
return {
|
||||
classes,
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
teacherName: teacherProfile?.name ?? "Teacher",
|
||||
gradeTrends,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生仪表盘数据(含派生指标)。
|
||||
* 权限:DASHBOARD_STUDENT_READ
|
||||
*/
|
||||
export async function getStudentDashboardAction(): Promise<{
|
||||
student: { id: string; name: string } | null
|
||||
dashboardProps: Omit<StudentDashboardProps, "studentName"> | null
|
||||
}> {
|
||||
await requirePermission(Permissions.DASHBOARD_STUDENT_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return { student: null, dashboardProps: null }
|
||||
}
|
||||
|
||||
const [classes, schedule, assignments, grades] = await Promise.all([
|
||||
getStudentClasses(student.id),
|
||||
getStudentSchedule(student.id),
|
||||
getStudentHomeworkAssignments(student.id),
|
||||
getStudentDashboardGrades(student.id),
|
||||
])
|
||||
|
||||
const now = new Date()
|
||||
const stats = countStudentAssignments(assignments, now)
|
||||
const todayWeekday = toWeekday(now)
|
||||
const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday)
|
||||
const upcomingAssignments = sortUpcomingAssignments(assignments, 6)
|
||||
|
||||
return {
|
||||
student: { id: student.id, name: student.name },
|
||||
dashboardProps: {
|
||||
enrolledClassCount: classes.length,
|
||||
dueSoonCount: stats.dueSoonCount,
|
||||
overdueCount: stats.overdueCount,
|
||||
gradedCount: stats.gradedCount,
|
||||
todayScheduleItems,
|
||||
upcomingAssignments,
|
||||
grades,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取家长仪表盘数据。
|
||||
* 权限:DASHBOARD_PARENT_READ
|
||||
*/
|
||||
export async function getParentDashboardAction(): Promise<{
|
||||
data: ParentDashboardData | null
|
||||
hasChildren: boolean
|
||||
}> {
|
||||
const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ)
|
||||
|
||||
// 非 admin 且 dataScope 非 children 类型时,无孩子数据
|
||||
if (
|
||||
ctx.dataScope.type !== "all" &&
|
||||
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
||||
) {
|
||||
return { data: null, hasChildren: false }
|
||||
}
|
||||
|
||||
const data = await getParentDashboardData(ctx.userId)
|
||||
return { data, hasChildren: data.children.length > 0 }
|
||||
}
|
||||
|
||||
/** 缓存版本(用于 RSC 直接调用,不走 Server Action 协议) */
|
||||
export const getCachedAdminDashboard = cache(getAdminDashboardAction)
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import {
|
||||
Activity,
|
||||
BookOpen,
|
||||
@@ -27,43 +28,45 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { UserGrowthChart } from "./user-growth-chart"
|
||||
|
||||
export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
export async function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="System overview across users, learning content, and activity."
|
||||
title={t("title.admin")}
|
||||
description={t("description.admin")}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/admin/users/import">
|
||||
<Upload className="h-4 w-4" />
|
||||
Import Users
|
||||
{t("quickActions.importUsers")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<Link href="/admin/announcements">
|
||||
<Megaphone className="h-4 w-4" />
|
||||
New Announcement
|
||||
{t("quickActions.newAnnouncement")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
{data.activeSessionsCount} active sessions
|
||||
{t("badge.activeSessions", { count: data.activeSessionsCount })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
{data.userCount} users
|
||||
{t("badge.users", { count: data.userCount })}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title="Users" value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
||||
<StatCard title="Classes" value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
||||
<StatCard title="Homework (published)" value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
||||
<StatCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
@@ -71,38 +74,38 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
<QuickActionCard
|
||||
href="/admin/users/import"
|
||||
icon={Upload}
|
||||
title="批量导入用户"
|
||||
description="通过 Excel 批量创建用户账号"
|
||||
title={t("quickActions.importUsers")}
|
||||
description={t("quickActions.importUsersDesc")}
|
||||
/>
|
||||
<QuickActionCard
|
||||
href="/admin/announcements"
|
||||
icon={Megaphone}
|
||||
title="发布公告"
|
||||
description="向全校或指定年级/班级发布通知"
|
||||
title={t("quickActions.newAnnouncement")}
|
||||
description={t("quickActions.newAnnouncementDesc")}
|
||||
/>
|
||||
<QuickActionCard
|
||||
href="/admin/scheduling/changes"
|
||||
icon={CalendarClock}
|
||||
title="审批课表变更"
|
||||
description="审核教师提交的课表变更与代课申请"
|
||||
title={t("quickActions.approveSchedule")}
|
||||
description={t("quickActions.approveScheduleDesc")}
|
||||
/>
|
||||
<QuickActionCard
|
||||
href="/admin/scheduling/auto"
|
||||
icon={CalendarClock}
|
||||
title="自动排课"
|
||||
description="基于规则自动生成周课表"
|
||||
title={t("quickActions.autoSchedule")}
|
||||
description={t("quickActions.autoScheduleDesc")}
|
||||
/>
|
||||
<QuickActionCard
|
||||
href="/admin/files"
|
||||
icon={FolderOpen}
|
||||
title="文件管理"
|
||||
description="查看与管理系统中所有上传文件"
|
||||
title={t("quickActions.fileManagement")}
|
||||
description={t("quickActions.fileManagementDesc")}
|
||||
/>
|
||||
<QuickActionCard
|
||||
href="/admin/attendance"
|
||||
icon={CalendarCheck}
|
||||
title="考勤总览"
|
||||
description="查看全校所有班级的考勤记录"
|
||||
title={t("quickActions.attendanceOverview")}
|
||||
description={t("quickActions.attendanceOverviewDesc")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +113,7 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">用户增长趋势(近30天)</CardTitle>
|
||||
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.userGrowth} />
|
||||
@@ -118,7 +121,7 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">作业提交趋势(近7天)</CardTitle>
|
||||
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.homeworkTrend} />
|
||||
@@ -129,11 +132,11 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>User Roles</CardTitle>
|
||||
<CardTitle>{t("sections.userRoles")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.userRoleCounts.length === 0 ? (
|
||||
<EmptyState title="No users" description="No user records found." />
|
||||
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
|
||||
) : (
|
||||
data.userRoleCounts.map((r) => (
|
||||
<div key={r.role} className="flex items-center justify-between">
|
||||
@@ -147,43 +150,43 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardTitle>{t("sections.content")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<ContentRow label="Textbooks" value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label="Chapters" value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label="Questions" value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label="Exams" value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Homework Activity</CardTitle>
|
||||
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<ContentRow label="Assignments" value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label="Submissions" value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label="To grade" value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Users</CardTitle>
|
||||
<CardTitle>{t("sections.recentUsers")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.recentUsers.length === 0 ? (
|
||||
<EmptyState title="No users yet" description="Seed the database to see users here." />
|
||||
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>{t("table.name")}</TableHead>
|
||||
<TableHead>{t("table.email")}</TableHead>
|
||||
<TableHead>{t("table.role")}</TableHead>
|
||||
<TableHead>{t("table.created")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -203,7 +206,7 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/users">
|
||||
查看全部用户
|
||||
{t("sections.viewAllUsers")}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -15,6 +16,8 @@ interface UserGrowthChartProps {
|
||||
}
|
||||
|
||||
export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
@@ -38,7 +41,7 @@ export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "hsl(var(--primary))", r: 3 }}
|
||||
name="新增用户"
|
||||
name={t("chart.newUsers")}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
interface StudentDashboardHeaderProps {
|
||||
studentName: string
|
||||
}
|
||||
|
||||
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
||||
const hour = new Date().getHours()
|
||||
let greeting = "Welcome back"
|
||||
if (hour < 12) greeting = "Good morning"
|
||||
else if (hour < 18) greeting = "Good afternoon"
|
||||
else greeting = "Good evening"
|
||||
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{greeting}, {studentName}. Here's what's happening today.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/student/schedule">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Schedule
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/student/learning/textbooks">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Textbooks
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<Link href="/student/learning/assignments">
|
||||
<PenTool className="h-4 w-4" />
|
||||
Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{studentName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { StudentStatsGrid } from "./student-stats-grid"
|
||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||
|
||||
export function StudentDashboard({
|
||||
export async function StudentDashboard({
|
||||
studentName,
|
||||
enrolledClassCount,
|
||||
dueSoonCount,
|
||||
|
||||
@@ -1,72 +1,77 @@
|
||||
import { BookOpen, CheckCircle, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import type { StudentRanking } from "@/modules/homework/types"
|
||||
|
||||
export function StudentStatsGrid({
|
||||
enrolledClassCount,
|
||||
dueSoonCount,
|
||||
overdueCount,
|
||||
gradedCount,
|
||||
ranking,
|
||||
}: {
|
||||
interface StudentStatsGridProps {
|
||||
enrolledClassCount: number
|
||||
dueSoonCount: number
|
||||
overdueCount: number
|
||||
gradedCount: number
|
||||
ranking: StudentRanking | null
|
||||
}) {
|
||||
}
|
||||
|
||||
export async function StudentStatsGrid({
|
||||
enrolledClassCount,
|
||||
dueSoonCount,
|
||||
overdueCount,
|
||||
gradedCount,
|
||||
ranking,
|
||||
}: StudentStatsGridProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Enrolled Classes"
|
||||
title={t("stats.enrolledClasses")}
|
||||
value={String(enrolledClassCount)}
|
||||
description="Active enrollments"
|
||||
description={t("stats.activeEnrollments")}
|
||||
icon={BookOpen}
|
||||
href="/student/learning/courses"
|
||||
color="text-emerald-500"
|
||||
valueClassName="text-emerald-500 tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Score"
|
||||
title={t("stats.averageScore")}
|
||||
value={ranking ? `${Math.round(ranking.percentage)}%` : "-"}
|
||||
description={ranking ? "Overall performance" : "No grades yet"}
|
||||
description={ranking ? t("stats.overallPerformance") : t("stats.noGradesYet")}
|
||||
icon={TrendingUp}
|
||||
href="/student/grades"
|
||||
color="text-blue-500"
|
||||
valueClassName={ranking ? "text-blue-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Class Rank"
|
||||
title={t("stats.classRank")}
|
||||
value={ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
||||
description={ranking ? "Current position" : "No ranking yet"}
|
||||
description={ranking ? t("stats.currentPosition") : t("stats.noRankingYet")}
|
||||
icon={Trophy}
|
||||
href="/student/grades"
|
||||
color="text-purple-500"
|
||||
valueClassName={ranking ? "text-purple-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Graded"
|
||||
title={t("stats.graded")}
|
||||
value={String(gradedCount)}
|
||||
description="Completed assignments"
|
||||
description={t("stats.completedAssignments")}
|
||||
icon={CheckCircle}
|
||||
href="/student/learning/assignments"
|
||||
color="text-green-500"
|
||||
valueClassName="text-green-500 tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="Due Soon"
|
||||
title={t("stats.dueSoon")}
|
||||
value={String(dueSoonCount)}
|
||||
description="Next 7 days"
|
||||
description={t("stats.next7Days")}
|
||||
icon={PenTool}
|
||||
href="/student/learning/assignments"
|
||||
color={dueSoonCount > 0 ? "text-orange-500" : undefined}
|
||||
valueClassName={dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Overdue"
|
||||
title={t("stats.overdue")}
|
||||
value={String(overdueCount)}
|
||||
description="Needs attention"
|
||||
description={t("stats.needsAttention")}
|
||||
icon={TriangleAlert}
|
||||
href="/student/learning/assignments"
|
||||
color={overdueCount > 0 ? "text-red-500" : undefined}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
interface TeacherDashboardHeaderProps {
|
||||
@@ -8,18 +10,17 @@ interface TeacherDashboardHeaderProps {
|
||||
}
|
||||
|
||||
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const hour = new Date().getHours()
|
||||
let greeting = "欢迎回来"
|
||||
if (hour < 12) greeting = "早上好"
|
||||
else if (hour < 18) greeting = "下午好"
|
||||
else greeting = "晚上好"
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{greeting},{teacherName}</h2>
|
||||
<p className="text-muted-foreground">今天是 {today},以下是今日概览。</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{teacherName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { TeacherDashboardData, TeacherTodayScheduleItem } from "@/modules/dashboard/types"
|
||||
import type { TeacherDashboardData } from "@/modules/dashboard/types"
|
||||
import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import type { TeacherTodoItem } from "./teacher-todo-card"
|
||||
|
||||
import { TeacherClassesCard } from "./teacher-classes-card"
|
||||
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
|
||||
@@ -7,52 +10,36 @@ 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"
|
||||
import { TeacherTodoCard } from "./teacher-todo-card"
|
||||
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
interface TeacherDashboardViewProps {
|
||||
data: TeacherDashboardData & { metrics: TeacherDashboardMetrics }
|
||||
}
|
||||
|
||||
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
||||
const todayWeekday = toWeekday(new Date())
|
||||
export async function TeacherDashboardView({ data }: TeacherDashboardViewProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const { metrics } = data
|
||||
|
||||
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
|
||||
const todayScheduleItems: TeacherTodayScheduleItem[] = data.schedule
|
||||
.filter((s) => s.weekday === todayWeekday)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
.map((s): TeacherTodayScheduleItem => ({
|
||||
id: s.id,
|
||||
classId: s.classId,
|
||||
className: classNameById.get(s.classId) ?? "Class",
|
||||
course: s.course,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location ?? null,
|
||||
}))
|
||||
|
||||
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
|
||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||
|
||||
const submissionsToGrade = submittedSubmissions
|
||||
.filter(s => s.status === "submitted")
|
||||
.sort((a, b) => (a.submittedAt ? new Date(a.submittedAt).getTime() : 0) - (b.submittedAt ? new Date(b.submittedAt).getTime() : 0))
|
||||
.slice(0, 6);
|
||||
|
||||
const activeAssignmentsCount = data.assignments.filter(a => a.status === "published").length
|
||||
|
||||
const totalTrendScore = data.gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
|
||||
const averageScore = data.gradeTrends.length > 0 ? totalTrendScore / data.gradeTrends.length : 0
|
||||
|
||||
const totalSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
|
||||
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" },
|
||||
{
|
||||
label: t("todo.toGrade"),
|
||||
count: metrics.toGradeCount,
|
||||
href: "/teacher/homework/submissions",
|
||||
variant: metrics.toGradeCount > 0 ? "urgent" : "normal",
|
||||
},
|
||||
{
|
||||
label: t("todo.todayAttendance"),
|
||||
count: metrics.todayScheduleItems.length,
|
||||
href: "/teacher/attendance/sheet",
|
||||
variant: "info",
|
||||
},
|
||||
{
|
||||
label: t("todo.activeAssignments"),
|
||||
count: metrics.activeAssignmentsCount,
|
||||
href: "/teacher/homework/assignments",
|
||||
variant: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -60,34 +47,34 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
|
||||
<TeacherStats
|
||||
toGradeCount={toGradeCount}
|
||||
activeAssignmentsCount={activeAssignmentsCount}
|
||||
averageScore={averageScore}
|
||||
submissionRate={submissionRate}
|
||||
toGradeCount={metrics.toGradeCount}
|
||||
activeAssignmentsCount={metrics.activeAssignmentsCount}
|
||||
averageScore={metrics.averageScore}
|
||||
submissionRate={metrics.submissionRate}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-8">
|
||||
<div className="lg:hidden">
|
||||
<TeacherSchedule items={todayScheduleItems} />
|
||||
</div>
|
||||
<TeacherTodoCard items={todoItems} />
|
||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||
<RecentSubmissions
|
||||
submissions={submissionsToGrade}
|
||||
title="待批改"
|
||||
emptyTitle="全部批改完成!"
|
||||
emptyDescription="暂无待批改的提交。"
|
||||
/>
|
||||
<div className="lg:hidden">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
</div>
|
||||
<TeacherTodoCard items={todoItems} />
|
||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||
<RecentSubmissions
|
||||
submissions={metrics.submissionsToGrade}
|
||||
title={t("sections.pendingGrading")}
|
||||
emptyTitle={t("empty.allGraded")}
|
||||
emptyDescription={t("empty.allGradedDesc")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-6 lg:col-span-4">
|
||||
<div className="hidden lg:block">
|
||||
<TeacherSchedule items={todayScheduleItems} />
|
||||
</div>
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
<div className="hidden lg:block">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
</div>
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
|
||||
interface TeacherStatsProps {
|
||||
toGradeCount: number;
|
||||
activeAssignmentsCount: number;
|
||||
averageScore: number;
|
||||
submissionRate: number;
|
||||
isLoading?: boolean;
|
||||
toGradeCount: number
|
||||
activeAssignmentsCount: number
|
||||
averageScore: number
|
||||
submissionRate: number
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function TeacherStats({
|
||||
export async function TeacherStats({
|
||||
toGradeCount,
|
||||
activeAssignmentsCount,
|
||||
averageScore,
|
||||
submissionRate,
|
||||
isLoading = false,
|
||||
}: TeacherStatsProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Needs Grading"
|
||||
title={t("stats.needsGrading")}
|
||||
value={String(toGradeCount)}
|
||||
description="Submissions pending review"
|
||||
description={t("stats.submissionsPendingReview")}
|
||||
icon={FileCheck}
|
||||
href="/teacher/homework/submissions?status=submitted"
|
||||
highlight={toGradeCount > 0}
|
||||
@@ -29,32 +32,32 @@ export function TeacherStats({
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Assignments"
|
||||
title={t("stats.activeAssignments")}
|
||||
value={String(activeAssignmentsCount)}
|
||||
description="Published and ongoing"
|
||||
description={t("stats.publishedAndOngoing")}
|
||||
icon={PenTool}
|
||||
href="/teacher/homework/assignments?status=published"
|
||||
color="text-blue-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Score"
|
||||
title={t("stats.averageScore")}
|
||||
value={`${Math.round(averageScore)}%`}
|
||||
description="Across recent assignments"
|
||||
description={t("stats.acrossRecentAssignments")}
|
||||
icon={TrendingUp}
|
||||
href="#grade-trends"
|
||||
color="text-emerald-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Submission Rate"
|
||||
title={t("stats.submissionRate")}
|
||||
value={`${Math.round(submissionRate)}%`}
|
||||
description="Overall completion rate"
|
||||
description={t("stats.overallCompletionRate")}
|
||||
icon={BarChart}
|
||||
href="#grade-trends"
|
||||
color="text-purple-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { ClipboardCheck, CalendarCheck, FileEdit, AlertCircle, ChevronRight } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export interface TeacherTodoItem {
|
||||
label: string
|
||||
count: number
|
||||
href: string
|
||||
variant: "urgent" | "normal" | "info"
|
||||
}
|
||||
|
||||
interface TeacherTodoCardProps {
|
||||
items: TeacherTodoItem[]
|
||||
}
|
||||
|
||||
const VARIANT_STYLES: Record<TeacherTodoItem["variant"], { icon: typeof AlertCircle; iconColor: string; badge: string }> = {
|
||||
urgent: { icon: AlertCircle, iconColor: "text-destructive", badge: "bg-destructive text-destructive-foreground" },
|
||||
normal: { icon: ClipboardCheck, iconColor: "text-amber-500", badge: "bg-amber-500 text-white" },
|
||||
info: { icon: CalendarCheck, iconColor: "text-blue-500", badge: "bg-blue-500 text-white" },
|
||||
}
|
||||
|
||||
export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasItems = items.some((item) => item.count > 0)
|
||||
const totalPending = items.reduce((acc, item) => acc + (item.count > 0 ? 1 : 0), 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileEdit className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
{t("todo.title")}
|
||||
{totalPending > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground tabular-nums">
|
||||
{totalPending}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasItems ? (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
<CalendarCheck className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("todo.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.count > 0)
|
||||
.sort((a, b) => (a.variant === "urgent" ? -1 : 1) - (b.variant === "urgent" ? -1 : 1))
|
||||
.map((item, idx) => {
|
||||
const style = VARIANT_STYLES[item.variant]
|
||||
const Icon = style.icon
|
||||
return (
|
||||
<Link
|
||||
key={idx}
|
||||
href={item.href}
|
||||
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={cn("h-4 w-4 shrink-0", style.iconColor)} aria-hidden="true" />
|
||||
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={cn("inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium tabular-nums", style.badge)}>
|
||||
{item.count}
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden="true" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
190
src/modules/dashboard/lib/dashboard-utils.ts
Normal file
190
src/modules/dashboard/lib/dashboard-utils.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 仪表盘纯逻辑工具函数(与 UI 分离,便于单测)。
|
||||
*
|
||||
* 所有函数均为纯函数:相同输入 → 相同输出,无副作用。
|
||||
*/
|
||||
|
||||
import type {
|
||||
HomeworkAssignmentListItem,
|
||||
HomeworkSubmissionListItem,
|
||||
StudentHomeworkAssignmentListItem,
|
||||
StudentHomeworkProgressStatus,
|
||||
TeacherGradeTrendItem,
|
||||
} from "@/modules/homework/types"
|
||||
import type { ClassScheduleItem, TeacherClass } from "@/modules/classes/types"
|
||||
|
||||
import type {
|
||||
StudentTodayScheduleItem,
|
||||
TeacherTodayScheduleItem,
|
||||
TeacherDashboardData,
|
||||
} from "@/modules/dashboard/types"
|
||||
|
||||
/** 周一=1 ... 周日=7 */
|
||||
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
/**
|
||||
* 将 Date 转换为 1-7 周几表示(周一=1,周日=7)。
|
||||
* getDay() 返回 0(周日)-6(周六),需映射为 1-7。
|
||||
*/
|
||||
export function toWeekday(d: Date): Weekday {
|
||||
const day = d.getDay()
|
||||
if (day < 0 || day > 6) {
|
||||
throw new Error(`Invalid day from getDay(): ${day}`)
|
||||
}
|
||||
const WEEKDAY_MAP: readonly Weekday[] = [7, 1, 2, 3, 4, 5, 6]
|
||||
return WEEKDAY_MAP[day]
|
||||
}
|
||||
|
||||
/** 学生作业统计结果 */
|
||||
export interface StudentAssignmentStats {
|
||||
dueSoonCount: number
|
||||
overdueCount: number
|
||||
gradedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次遍历统计学生作业状态:即将到期 / 已逾期 / 已批改。
|
||||
*/
|
||||
export function countStudentAssignments(
|
||||
assignments: readonly StudentHomeworkAssignmentListItem[],
|
||||
now: Date,
|
||||
dueSoonWindowDays = 7,
|
||||
): StudentAssignmentStats {
|
||||
const in7Days = new Date(now)
|
||||
in7Days.setDate(in7Days.getDate() + dueSoonWindowDays)
|
||||
|
||||
let dueSoonCount = 0
|
||||
let overdueCount = 0
|
||||
let gradedCount = 0
|
||||
|
||||
for (const a of assignments) {
|
||||
const status: StudentHomeworkProgressStatus = a.progressStatus
|
||||
if (status === "graded") {
|
||||
gradedCount++
|
||||
continue
|
||||
}
|
||||
if (!a.dueAt) continue
|
||||
const due = new Date(a.dueAt)
|
||||
if (due >= now && due <= in7Days) {
|
||||
dueSoonCount++
|
||||
} else if (due < now) {
|
||||
overdueCount++
|
||||
}
|
||||
}
|
||||
|
||||
return { dueSoonCount, overdueCount, gradedCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按截止日期升序排序作业,取前 N 条作为「即将到期」列表。
|
||||
* 无截止日期的作业排到最后。
|
||||
*/
|
||||
export function sortUpcomingAssignments(
|
||||
assignments: readonly StudentHomeworkAssignmentListItem[],
|
||||
limit = 6,
|
||||
): StudentHomeworkAssignmentListItem[] {
|
||||
return [...assignments]
|
||||
.sort((a, b) => {
|
||||
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||
return aDue - bDue
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从课表中筛选指定周几的课程,按开始时间升序排序。
|
||||
*/
|
||||
export function filterTodaySchedule(
|
||||
schedule: readonly ClassScheduleItem[],
|
||||
weekday: Weekday,
|
||||
classNameById?: ReadonlyMap<string, string>,
|
||||
): StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] {
|
||||
return schedule
|
||||
.filter((s) => s.weekday === weekday)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
classId: s.classId,
|
||||
className: classNameById?.get(s.classId) ?? "Class",
|
||||
course: s.course,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location ?? null,
|
||||
})) as StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]
|
||||
}
|
||||
|
||||
/** 教师仪表盘派生指标 */
|
||||
export interface TeacherDashboardMetrics {
|
||||
todayScheduleItems: TeacherTodayScheduleItem[]
|
||||
toGradeCount: number
|
||||
submissionsToGrade: HomeworkSubmissionListItem[]
|
||||
activeAssignmentsCount: number
|
||||
averageScore: number
|
||||
submissionRate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算教师仪表盘派生指标:待批改数、进行中作业数、平均分、提交率等。
|
||||
*/
|
||||
export function computeTeacherMetrics(
|
||||
classes: readonly TeacherClass[],
|
||||
schedule: readonly ClassScheduleItem[],
|
||||
assignments: readonly HomeworkAssignmentListItem[],
|
||||
submissions: readonly HomeworkSubmissionListItem[],
|
||||
gradeTrends: readonly TeacherGradeTrendItem[],
|
||||
now: Date,
|
||||
): TeacherDashboardMetrics {
|
||||
const todayWeekday = toWeekday(now)
|
||||
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
|
||||
|
||||
const todayScheduleItems = filterTodaySchedule(
|
||||
schedule,
|
||||
todayWeekday,
|
||||
classNameById,
|
||||
) as TeacherTodayScheduleItem[]
|
||||
|
||||
const submittedSubmissions = submissions.filter((s) => Boolean(s.submittedAt))
|
||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||
|
||||
const submissionsToGrade = submittedSubmissions
|
||||
.filter((s) => s.status === "submitted")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.submittedAt ? new Date(a.submittedAt).getTime() : 0) -
|
||||
(b.submittedAt ? new Date(b.submittedAt).getTime() : 0),
|
||||
)
|
||||
.slice(0, 6)
|
||||
|
||||
const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length
|
||||
|
||||
const totalTrendScore = gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
|
||||
const averageScore = gradeTrends.length > 0 ? totalTrendScore / gradeTrends.length : 0
|
||||
|
||||
const totalSubmissions = gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
|
||||
const totalPotentialSubmissions = gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
|
||||
const submissionRate =
|
||||
totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
|
||||
|
||||
return {
|
||||
todayScheduleItems,
|
||||
toGradeCount,
|
||||
submissionsToGrade,
|
||||
activeAssignmentsCount,
|
||||
averageScore,
|
||||
submissionRate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前小时返回问候语时段 key(morning / afternoon / evening)。
|
||||
*/
|
||||
export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" {
|
||||
const hour = now.getHours()
|
||||
if (hour < 12) return "morning"
|
||||
if (hour < 18) return "afternoon"
|
||||
return "evening"
|
||||
}
|
||||
|
||||
/** 重导出 TeacherDashboardData 便于 actions 使用 */
|
||||
export type { TeacherDashboardData }
|
||||
@@ -1,74 +1,106 @@
|
||||
import Link from "next/link"
|
||||
import { CalendarCheck, CalendarDays, GraduationCap, Users } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import {
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
GraduationCap,
|
||||
Megaphone,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChildCard } from "./child-card"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { ChildCard } from "./child-card"
|
||||
import { ParentAttentionBanner } from "./parent-attention-banner"
|
||||
|
||||
export function ParentDashboard({ data }: { data: ParentDashboardData }) {
|
||||
export async function ParentDashboard({ data }: { data: ParentDashboardData }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const { parentName, children } = data
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
const hour = new Date().getHours()
|
||||
let greeting = "Welcome"
|
||||
if (hour < 12) greeting = "Good morning"
|
||||
else if (hour < 18) greeting = "Good afternoon"
|
||||
else greeting = "Good evening"
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ href: "/parent/grades", label: t("quickActions.grades"), icon: GraduationCap },
|
||||
{ href: "/parent/attendance", label: t("quickActions.attendance"), icon: CalendarCheck },
|
||||
{ href: "/announcements", label: t("quickActions.announcements"), icon: Megaphone },
|
||||
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Parent Dashboard</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{greeting}
|
||||
{parentName ? `, ${parentName}` : ""}. Here's an overview of your children.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/parent/grades">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Grades
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/parent/attendance">
|
||||
<CalendarCheck className="h-4 w-4" />
|
||||
Attendance
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/announcements">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Announcements
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(`greeting.${greetingKey}`)}
|
||||
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasChildren ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No children linked"
|
||||
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<ParentAttentionBanner data={data} />
|
||||
|
||||
<nav
|
||||
aria-label={t("quickActions.announcements")}
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
{QUICK_ENTRIES.map((entry) => (
|
||||
<Link
|
||||
key={entry.href}
|
||||
href={entry.href}
|
||||
className="group"
|
||||
aria-label={entry.label}
|
||||
>
|
||||
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<entry.icon
|
||||
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<Users className="h-4 w-4" aria-hidden />
|
||||
<span>
|
||||
{children.length} {children.length === 1 ? "child" : "children"} linked
|
||||
{t("badge.childrenLinked", { count: children.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* 移动端水平滑动卡片,桌面端网格布局 */}
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
|
||||
aria-label={t("title.parent")}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
|
||||
<ChildCard child={child} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{children.map((child) => (
|
||||
<ChildCard key={child.basicInfo.id} child={child} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("empty.noChildren")}
|
||||
description={t("empty.noChildrenDesc")}
|
||||
className="border-none shadow-none"
|
||||
action={{
|
||||
label: t("empty.contactSupport"),
|
||||
href: "/messages",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user