feat(app): add error/loading boundaries and update dashboard routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add dashboard-error-fallback and dashboard-loading-skeleton components - Add student/learning page, parent/leave routes, teacher textbook components - Update existing app routes across auth, dashboard, and API endpoints - Update proxy middleware and next-auth type declarations
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"use server"
|
||||
|
||||
import { cache } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import {
|
||||
getHomeworkAssignments,
|
||||
@@ -19,6 +19,7 @@ import { getAdminDashboardData } from "./data-access"
|
||||
import type {
|
||||
AdminDashboardData,
|
||||
StudentDashboardProps,
|
||||
StudentTodayScheduleItem,
|
||||
TeacherDashboardData,
|
||||
} from "./types"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
@@ -35,47 +36,59 @@ import {
|
||||
* 获取管理员仪表盘数据。
|
||||
* 权限:DASHBOARD_ADMIN_READ
|
||||
*/
|
||||
export async function getAdminDashboardAction(): Promise<AdminDashboardData> {
|
||||
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
||||
return getAdminDashboardData(ctx.dataScope)
|
||||
export async function getAdminDashboardAction(): Promise<ActionState<AdminDashboardData>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
||||
const data = await getAdminDashboardData(ctx.dataScope)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师仪表盘数据(含派生指标)。
|
||||
* 权限:DASHBOARD_TEACHER_READ
|
||||
*/
|
||||
export async function getTeacherDashboardAction(): Promise<TeacherDashboardData & {
|
||||
export async function getTeacherDashboardAction(): Promise<ActionState<TeacherDashboardData & {
|
||||
metrics: TeacherDashboardMetrics
|
||||
}> {
|
||||
await requirePermission(Permissions.DASHBOARD_TEACHER_READ)
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
}>> {
|
||||
try {
|
||||
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 [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(),
|
||||
)
|
||||
const metrics = computeTeacherMetrics(
|
||||
classes,
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
gradeTrends,
|
||||
new Date(),
|
||||
)
|
||||
|
||||
return {
|
||||
classes,
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
teacherName: teacherProfile?.name ?? "Teacher",
|
||||
gradeTrends,
|
||||
metrics,
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
classes,
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
teacherName: teacherProfile?.name ?? "Teacher",
|
||||
gradeTrends,
|
||||
metrics,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,40 +96,47 @@ export async function getTeacherDashboardAction(): Promise<TeacherDashboardData
|
||||
* 获取学生仪表盘数据(含派生指标)。
|
||||
* 权限:DASHBOARD_STUDENT_READ
|
||||
*/
|
||||
export async function getStudentDashboardAction(): Promise<{
|
||||
export async function getStudentDashboardAction(): Promise<ActionState<{
|
||||
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 }
|
||||
}
|
||||
}>> {
|
||||
try {
|
||||
await requirePermission(Permissions.DASHBOARD_STUDENT_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return { success: true, data: { 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 [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)
|
||||
const now = new Date()
|
||||
const stats = countStudentAssignments(assignments, now)
|
||||
const todayWeekday = toWeekday(now)
|
||||
const todayScheduleItems = filterTodaySchedule<StudentTodayScheduleItem>(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,
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
student: { id: student.id, name: student.name },
|
||||
dashboardProps: {
|
||||
enrolledClassCount: classes.length,
|
||||
dueSoonCount: stats.dueSoonCount,
|
||||
overdueCount: stats.overdueCount,
|
||||
gradedCount: stats.gradedCount,
|
||||
todayScheduleItems,
|
||||
upcomingAssignments,
|
||||
grades,
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,23 +144,25 @@ export async function getStudentDashboardAction(): Promise<{
|
||||
* 获取家长仪表盘数据。
|
||||
* 权限:DASHBOARD_PARENT_READ
|
||||
*/
|
||||
export async function getParentDashboardAction(): Promise<{
|
||||
export async function getParentDashboardAction(): Promise<ActionState<{
|
||||
data: ParentDashboardData | null
|
||||
hasChildren: boolean
|
||||
}> {
|
||||
const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ)
|
||||
}>> {
|
||||
try {
|
||||
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 }
|
||||
// 非 admin 且 dataScope 非 children 类型时,无孩子数据
|
||||
if (
|
||||
ctx.dataScope.type !== "all" &&
|
||||
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
||||
) {
|
||||
return { success: true, data: { data: null, hasChildren: false } }
|
||||
}
|
||||
|
||||
const data = await getParentDashboardData(ctx.userId)
|
||||
return { success: true, data: { data, hasChildren: data.children.length > 0 } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
|
||||
const data = await getParentDashboardData(ctx.userId)
|
||||
return { data, hasChildren: data.children.length > 0 }
|
||||
}
|
||||
|
||||
/** 缓存版本(用于 RSC 直接调用,不走 Server Action 协议) */
|
||||
export const getCachedAdminDashboard = cache(getAdminDashboardAction)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -11,13 +12,28 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
interface UserGrowthChartProps {
|
||||
data: Array<{ date: string; count: number }>
|
||||
/** Translation key for the line/tooltip label (e.g. "chart.newUsers" or "chart.newSubmissions") */
|
||||
labelKey?: "chart.newUsers" | "chart.newSubmissions"
|
||||
}
|
||||
|
||||
export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
||||
export function UserGrowthChart({ data, labelKey = "chart.newUsers" }: UserGrowthChartProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("empty.noData")}
|
||||
description={t("empty.noDataDesc")}
|
||||
className="border-none h-[240px]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
@@ -41,7 +57,7 @@ export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "hsl(var(--primary))", r: 3 }}
|
||||
name={t("chart.newUsers")}
|
||||
name={t(labelKey)}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* 仪表盘通用错误边界回退 UI。
|
||||
*
|
||||
* 用于各角色 dashboard 路由的 `error.tsx`,消除重复代码。
|
||||
* 接收 Next.js 注入的 `reset` 函数用于重试。
|
||||
*/
|
||||
export function DashboardErrorFallback({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("dashboard")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
|
||||
@@ -11,15 +9,16 @@ import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
|
||||
* 通过 `actions` slot 注入角色专属快捷操作。
|
||||
*/
|
||||
export function DashboardGreetingHeader({
|
||||
export async function DashboardGreetingHeader({
|
||||
userName,
|
||||
actions,
|
||||
}: {
|
||||
userName: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const t = await getTranslations("dashboard")
|
||||
const locale = await getLocale()
|
||||
const today = formatLongDate(new Date(), locale)
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* 仪表盘通用加载骨架屏。
|
||||
*
|
||||
* 用于各角色 dashboard 路由的 `loading.tsx`,消除重复代码。
|
||||
* 布局:页头 + 4 列统计卡片 + 列表骨架。
|
||||
*/
|
||||
export function DashboardLoadingSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
|
||||
interface StudentDashboardHeaderProps {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
@@ -13,6 +13,7 @@ import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
|
||||
const t = useTranslations("dashboard")
|
||||
const locale = useLocale()
|
||||
const hasGradeTrend = grades.trend.length > 0
|
||||
const hasRecentGrades = grades.recent.length > 0
|
||||
|
||||
@@ -20,7 +21,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
title: item.assignmentTitle,
|
||||
score: Math.round(item.percentage),
|
||||
fullTitle: item.assignmentTitle,
|
||||
submittedAt: formatDate(item.submittedAt),
|
||||
submittedAt: formatDate(item.submittedAt, locale),
|
||||
rawScore: item.score,
|
||||
maxScore: item.maxScore,
|
||||
}))
|
||||
@@ -102,7 +103,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
<TableCell className="tabular-nums">
|
||||
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt, locale)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { PenTool } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -27,7 +27,7 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
|
||||
return "default"
|
||||
}
|
||||
|
||||
const getDueUrgency = (dueAt: string | null) => {
|
||||
const getDueUrgency = (dueAt: string | null): "overdue" | "urgent" | "warning" | "normal" | null => {
|
||||
if (!dueAt) return null
|
||||
const now = new Date()
|
||||
const due = new Date(dueAt)
|
||||
@@ -41,6 +41,7 @@ const getDueUrgency = (dueAt: string | null) => {
|
||||
|
||||
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const locale = await getLocale()
|
||||
const hasAssignments = upcomingAssignments.length > 0
|
||||
|
||||
return (
|
||||
@@ -60,6 +61,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
|
||||
icon={PenTool}
|
||||
title={t("empty.noAssignmentsStudent")}
|
||||
description={t("empty.noAssignmentsStudentDesc")}
|
||||
action={{ label: t("quickActions.viewAll"), href: "/student/learning/assignments" }}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
@@ -103,7 +105,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
|
||||
!isGraded && urgency === "overdue" && "text-destructive font-medium",
|
||||
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
|
||||
)}>
|
||||
{a.dueAt ? formatDate(a.dueAt) : "-"}
|
||||
{a.dueAt ? formatDate(a.dueAt, locale) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getTranslations, getLocale } from "next-intl/server"
|
||||
import { Inbox, ArrowRight } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -32,6 +32,7 @@ export async function RecentSubmissions({
|
||||
emptyDescription,
|
||||
}: RecentSubmissionsProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const locale = await getLocale()
|
||||
const hasSubmissions = submissions.length > 0
|
||||
|
||||
return (
|
||||
@@ -73,7 +74,6 @@ export async function RecentSubmissions({
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8 border">
|
||||
<AvatarImage src={undefined} alt={item.studentName} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||
{item.studentName.charAt(0)}
|
||||
</AvatarFallback>
|
||||
@@ -93,7 +93,7 @@ export async function RecentSubmissions({
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
|
||||
{item.submittedAt ? formatDate(item.submittedAt, locale) : "-"}
|
||||
</span>
|
||||
{item.isLate && (
|
||||
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
.reverse()
|
||||
.slice(0, 3)
|
||||
.map((item, i) => (
|
||||
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
||||
<div key={item.fullTitle || `item-${i}`} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
||||
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
|
||||
{item.fullTitle}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, CheckSquare, Users } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function TeacherQuickActions() {
|
||||
const t = useTranslations("dashboard")
|
||||
export async function TeacherQuickActions() {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -21,14 +21,14 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasSchedule = items.length > 0
|
||||
|
||||
const getStatus = (start: string, end: string) => {
|
||||
const getStatus = (start: string, end: string): "live" | "upcoming" | "past" => {
|
||||
const now = new Date()
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||
|
||||
const [startH, startM] = start.split(":").map(Number)
|
||||
const [endH, endM] = end.split(":").map(Number)
|
||||
const startTime = (startH ?? 0) * 60 + (startM ?? 0)
|
||||
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
|
||||
const startTime = (Number.isFinite(startH) ? startH : 0) * 60 + (Number.isFinite(startM) ? startM : 0)
|
||||
const endTime = (Number.isFinite(endH) ? endH : 0) * 60 + (Number.isFinite(endM) ? endM : 0)
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||
if (currentTime < startTime) return "upcoming"
|
||||
|
||||
@@ -7,7 +7,6 @@ interface TeacherStatsProps {
|
||||
activeAssignmentsCount: number
|
||||
averageScore: number
|
||||
submissionRate: number
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export async function TeacherStats({
|
||||
@@ -15,7 +14,6 @@ export async function TeacherStats({
|
||||
activeAssignmentsCount,
|
||||
averageScore,
|
||||
submissionRate,
|
||||
isLoading = false,
|
||||
}: TeacherStatsProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
@@ -29,7 +27,6 @@ export async function TeacherStats({
|
||||
href="/teacher/homework/submissions?status=submitted"
|
||||
highlight={toGradeCount > 0}
|
||||
color="text-amber-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("stats.activeAssignments")}
|
||||
@@ -38,7 +35,6 @@ export async function TeacherStats({
|
||||
icon={PenTool}
|
||||
href="/teacher/homework/assignments?status=published"
|
||||
color="text-blue-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("stats.averageScore")}
|
||||
@@ -47,7 +43,6 @@ export async function TeacherStats({
|
||||
icon={TrendingUp}
|
||||
href="#grade-trends"
|
||||
color="text-emerald-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("stats.submissionRate")}
|
||||
@@ -56,7 +51,6 @@ export async function TeacherStats({
|
||||
icon={BarChart}
|
||||
href="#grade-trends"
|
||||
color="text-purple-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -49,13 +49,17 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
|
||||
<div className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.count > 0)
|
||||
.sort((a, b) => (a.variant === "urgent" ? -1 : 1) - (b.variant === "urgent" ? -1 : 1))
|
||||
.sort((a, b) => {
|
||||
if (a.variant === "urgent" && b.variant !== "urgent") return -1
|
||||
if (a.variant !== "urgent" && b.variant === "urgent") return 1
|
||||
return 0
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const style = VARIANT_STYLES[item.variant]
|
||||
const Icon = style.icon
|
||||
return (
|
||||
<Link
|
||||
key={idx}
|
||||
key={item.href || item.label || `item-${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"
|
||||
>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function countStudentAssignments(
|
||||
}
|
||||
if (!a.dueAt) continue
|
||||
const due = new Date(a.dueAt)
|
||||
if (Number.isNaN(due.getTime())) continue
|
||||
if (due >= now && due <= in7Days) {
|
||||
dueSoonCount++
|
||||
} else if (due < now) {
|
||||
@@ -85,8 +86,10 @@ export function sortUpcomingAssignments(
|
||||
): 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
|
||||
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||
const aDue = Number.isNaN(aTime) ? Number.POSITIVE_INFINITY : aTime
|
||||
const bDue = Number.isNaN(bTime) ? Number.POSITIVE_INFINITY : bTime
|
||||
return aDue - bDue
|
||||
})
|
||||
.slice(0, limit)
|
||||
@@ -94,12 +97,15 @@ export function sortUpcomingAssignments(
|
||||
|
||||
/**
|
||||
* 从课表中筛选指定周几的课程,按开始时间升序排序。
|
||||
*
|
||||
* 泛型 T 允许调用方指定返回的课表项类型(StudentTodayScheduleItem 或
|
||||
* TeacherTodayScheduleItem)。两者结构完全相同,泛型仅用于类型层面。
|
||||
*/
|
||||
export function filterTodaySchedule(
|
||||
export function filterTodaySchedule<T extends StudentTodayScheduleItem | TeacherTodayScheduleItem = StudentTodayScheduleItem | TeacherTodayScheduleItem>(
|
||||
schedule: readonly ClassScheduleItem[],
|
||||
weekday: Weekday,
|
||||
classNameById?: ReadonlyMap<string, string>,
|
||||
): StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] {
|
||||
): T[] {
|
||||
return schedule
|
||||
.filter((s) => s.weekday === weekday)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
@@ -111,7 +117,7 @@ export function filterTodaySchedule(
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location ?? null,
|
||||
})) as StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]
|
||||
})) as T[]
|
||||
}
|
||||
|
||||
/** 教师仪表盘派生指标 */
|
||||
@@ -138,22 +144,24 @@ export function computeTeacherMetrics(
|
||||
const todayWeekday = toWeekday(now)
|
||||
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
|
||||
|
||||
const todayScheduleItems = filterTodaySchedule(
|
||||
const todayScheduleItems = filterTodaySchedule<TeacherTodayScheduleItem>(
|
||||
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),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.submittedAt ? new Date(a.submittedAt).getTime() : 0
|
||||
const bTime = b.submittedAt ? new Date(b.submittedAt).getTime() : 0
|
||||
const aDue = Number.isNaN(aTime) ? 0 : aTime
|
||||
const bDue = Number.isNaN(bTime) ? 0 : bTime
|
||||
return aDue - bDue
|
||||
})
|
||||
.slice(0, 6)
|
||||
|
||||
const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length
|
||||
|
||||
Reference in New Issue
Block a user