Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import Link from "next/link"
|
|
import { redirect } from "next/navigation"
|
|
|
|
import { auth } from "@/auth"
|
|
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
|
|
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
|
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
|
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
|
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 { Permissions } from "@/shared/types/permissions"
|
|
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"
|
|
import { Separator } from "@/shared/components/ui/separator"
|
|
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
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
|
|
}
|
|
|
|
const formatDate = (date: Date | null) => {
|
|
if (!date) return "-"
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
}).format(date)
|
|
}
|
|
|
|
export default async function ProfilePage() {
|
|
const session = await auth()
|
|
if (!session?.user) redirect("/login")
|
|
|
|
const userId = String(session.user.id ?? "").trim()
|
|
const userProfile = await getUserProfile(userId)
|
|
|
|
if (!userProfile) {
|
|
redirect("/login")
|
|
}
|
|
|
|
const permissions = session.user.permissions ?? []
|
|
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
|
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
|
|
|
const studentData =
|
|
isStudent
|
|
? await (async () => {
|
|
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
|
getStudentClasses(userId),
|
|
getStudentSchedule(userId),
|
|
getStudentHomeworkAssignments(userId),
|
|
getStudentDashboardGrades(userId),
|
|
])
|
|
|
|
const now = new Date()
|
|
const in7Days = new Date(now)
|
|
in7Days.setDate(in7Days.getDate() + 7)
|
|
|
|
const dueSoonCount = assignmentsAll.filter((a) => {
|
|
if (!a.dueAt) return false
|
|
const due = new Date(a.dueAt)
|
|
return due >= now && due <= in7Days && a.progressStatus !== "graded"
|
|
}).length
|
|
|
|
const overdueCount = assignmentsAll.filter((a) => {
|
|
if (!a.dueAt) return false
|
|
const due = new Date(a.dueAt)
|
|
return due < now && a.progressStatus !== "graded"
|
|
}).length
|
|
|
|
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
|
|
|
|
const upcomingAssignments = [...assignmentsAll]
|
|
.sort((a, b) => {
|
|
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
|
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
|
if (aTime !== bTime) return aTime - bTime
|
|
return a.id.localeCompare(b.id)
|
|
})
|
|
.slice(0, 8)
|
|
|
|
const todayWeekday = toWeekday(now)
|
|
const todayScheduleItems = schedule
|
|
.filter((s) => s.weekday === todayWeekday)
|
|
.map((s) => ({
|
|
id: s.id,
|
|
classId: s.classId,
|
|
className: s.className,
|
|
course: s.course,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
location: s.location ?? null,
|
|
}))
|
|
|
|
return {
|
|
enrolledClassCount: classes.length,
|
|
dueSoonCount,
|
|
overdueCount,
|
|
gradedCount,
|
|
todayScheduleItems,
|
|
upcomingAssignments,
|
|
grades,
|
|
}
|
|
})()
|
|
: null
|
|
|
|
const teacherData =
|
|
isTeacher
|
|
? await (async () => {
|
|
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
|
|
return { subjects, classes }
|
|
})()
|
|
: null
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-8 p-8">
|
|
<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">Profile</h1>
|
|
<div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button asChild variant="outline">
|
|
<Link href="/settings">Edit Profile</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<User className="h-5 w-5" />
|
|
Personal Information
|
|
</CardTitle>
|
|
<CardDescription>Basic personal details.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
|
|
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Gender</div>
|
|
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Age</div>
|
|
<div className="text-sm">{userProfile.age ?? "-"}</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Phone</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
|
|
{userProfile.phone ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Address</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
|
|
{userProfile.address ?? "-"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5" />
|
|
Account Information
|
|
</CardTitle>
|
|
<CardDescription>System account details.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Email</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Mail className="h-3 w-3 text-muted-foreground" />
|
|
{userProfile.email}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Role</div>
|
|
<Badge variant="secondary" className="capitalize">
|
|
{userProfile.role}
|
|
</Badge>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Calendar className="h-3 w-3 text-muted-foreground" />
|
|
{formatDate(userProfile.createdAt)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
|
{formatDate(userProfile.onboardedAt)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{studentData ? (
|
|
<div className="space-y-6">
|
|
<Separator />
|
|
<div className="space-y-1">
|
|
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
|
|
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
|
|
</div>
|
|
|
|
<StudentStatsGrid
|
|
enrolledClassCount={studentData.enrolledClassCount}
|
|
dueSoonCount={studentData.dueSoonCount}
|
|
overdueCount={studentData.overdueCount}
|
|
gradedCount={studentData.gradedCount}
|
|
ranking={studentData.grades.ranking}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
|
<StudentGradesCard grades={studentData.grades} />
|
|
</div>
|
|
<div className="space-y-6">
|
|
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{teacherData ? (
|
|
<div className="space-y-6">
|
|
<Separator />
|
|
<div className="space-y-1">
|
|
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
|
|
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Teaching Subjects</CardTitle>
|
|
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{teacherData.subjects.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{teacherData.subjects.map((subject) => (
|
|
<Badge key={subject} variant="secondary">
|
|
{subject}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Teaching Classes</CardTitle>
|
|
<CardDescription>Classes you are currently managing.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{teacherData.classes.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
|
|
) : (
|
|
teacherData.classes.map((cls) => (
|
|
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-medium">{cls.name}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{cls.grade}
|
|
{cls.homeroom ? ` • ${cls.homeroom}` : ""}
|
|
</div>
|
|
</div>
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|