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:
SpecialX
2026-06-22 15:50:56 +08:00
parent 2548f70f40
commit 868ac5f9cf
28 changed files with 1507 additions and 399 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
)
}

View File

@@ -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>
)
}