From 1abf58c0b63f939b49d7d314c120dbcd1e4d8202 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:37:49 +0800 Subject: [PATCH] feat(parent): add attention banner, export button, and grade detail - Add parent-attention-banner for highlighting items needing attention - Add parent-export-button for data export capability - Add child-grade-detail component for detailed grade viewing - Update existing child-card, child-detail-header, child-grade-summary, child-schedule-card - Update parent-children-data-page and data-access --- src/modules/parent/components/child-card.tsx | 80 +++++++-- .../parent/components/child-detail-header.tsx | 37 +++- .../parent/components/child-grade-detail.tsx | 168 ++++++++++++++++++ .../parent/components/child-grade-summary.tsx | 56 ++++-- .../parent/components/child-schedule-card.tsx | 79 +++++++- .../components/parent-attention-banner.tsx | 135 ++++++++++++++ .../components/parent-children-data-page.tsx | 5 + .../components/parent-export-button.tsx | 85 +++++++++ src/modules/parent/data-access.ts | 18 +- 9 files changed, 632 insertions(+), 31 deletions(-) create mode 100644 src/modules/parent/components/child-grade-detail.tsx create mode 100644 src/modules/parent/components/parent-attention-banner.tsx create mode 100644 src/modules/parent/components/parent-export-button.tsx diff --git a/src/modules/parent/components/child-card.tsx b/src/modules/parent/components/child-card.tsx index 548c142..d1d1d16 100644 --- a/src/modules/parent/components/child-card.tsx +++ b/src/modules/parent/components/child-card.tsx @@ -1,5 +1,13 @@ import Link from "next/link" -import { ChevronRight, GraduationCap, PenTool, TriangleAlert } from "lucide-react" +import { + AlertTriangle, + ChevronRight, + GraduationCap, + PenTool, + TrendingDown, + TrendingUp, + Minus, +} from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Badge } from "@/shared/components/ui/badge" @@ -7,19 +15,51 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui import { cn, getInitials } from "@/shared/lib/utils" import type { ChildDashboardData } from "@/modules/parent/types" +type TrendDirection = "up" | "down" | "flat" | "unknown" + +const computeTrend = (trend: { percentage: number }[]): TrendDirection => { + if (trend.length < 2) return "unknown" + const latest = trend[trend.length - 1].percentage + const prev = trend[trend.length - 2].percentage + if (latest > prev) return "up" + if (latest < prev) return "down" + return "flat" +} + +const TrendIcon = ({ direction }: { direction: TrendDirection }) => { + if (direction === "up") { + return + } + if (direction === "down") { + return + } + if (direction === "flat") { + return + } + return null +} + export function ChildCard({ child }: { child: ChildDashboardData }) { const { basicInfo, homeworkSummary, gradeTrend } = child const ranking = gradeTrend.ranking const latestGrade = gradeTrend.recent[0] const childName = basicInfo.name ?? "Child" + const hasOverdue = homeworkSummary.overdueCount > 0 + const trendDirection = computeTrend(gradeTrend.trend) return ( - + @@ -37,28 +77,45 @@ export function ChildCard({ child }: { child: ChildDashboardData }) { {basicInfo.relation ? · {basicInfo.relation} : null} - + {hasOverdue ? ( + + ) : ( + + )}
- + Pending
{homeworkSummary.pendingCount}
-
-
- +
+
+ Overdue
0 && "text-destructive", + "text-lg font-bold tabular-nums", + hasOverdue && "text-destructive", )} > {homeworkSummary.overdueCount} @@ -66,7 +123,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
- + Avg
@@ -80,6 +137,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) { {latestGrade.score}/{latestGrade.maxScore} + ({latestGrade.assignmentTitle})
) : null} diff --git a/src/modules/parent/components/child-detail-header.tsx b/src/modules/parent/components/child-detail-header.tsx index 7ccfad5..0622095 100644 --- a/src/modules/parent/components/child-detail-header.tsx +++ b/src/modules/parent/components/child-detail-header.tsx @@ -4,6 +4,14 @@ import { ArrowLeft, GraduationCap } from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/shared/components/ui/breadcrumb" import { getInitials } from "@/shared/lib/utils" import type { ChildDashboardData } from "@/modules/parent/types" @@ -21,12 +29,27 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) { return (
- +
+ + + + + Parent Dashboard + + + + + {childName} + + + + +
@@ -40,7 +63,7 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) { ) : null} {basicInfo.gradeName ? ( - + {basicInfo.gradeName} ) : null} diff --git a/src/modules/parent/components/child-grade-detail.tsx b/src/modules/parent/components/child-grade-detail.tsx new file mode 100644 index 0000000..ad8f586 --- /dev/null +++ b/src/modules/parent/components/child-grade-detail.tsx @@ -0,0 +1,168 @@ +import { BarChart3, TrendingDown, TrendingUp, Minus } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { formatDate } from "@/shared/lib/utils" +import type { StudentDashboardGradeProps } from "@/modules/homework/types" + +type TrendDirection = "up" | "down" | "flat" | "unknown" + +const computeTrend = (scores: number[]): TrendDirection => { + if (scores.length < 2) return "unknown" + const latest = scores[scores.length - 1] + const prev = scores[scores.length - 2] + if (latest > prev) return "up" + if (latest < prev) return "down" + return "flat" +} + +const TrendIcon = ({ direction }: { direction: TrendDirection }) => { + if (direction === "up") { + return + } + if (direction === "down") { + return + } + if (direction === "flat") { + return + } + return null +} + +type SubjectGroup = { + subject: string + items: StudentDashboardGradeProps["trend"] + average: number + count: number +} + +const groupBySubject = ( + trend: StudentDashboardGradeProps["trend"], +): SubjectGroup[] => { + const map = new Map() + for (const item of trend) { + const subject = item.assignmentTitle.split(" - ")[0]?.trim() || "General" + const list = map.get(subject) ?? [] + list.push(item) + map.set(subject, list) + } + const groups: SubjectGroup[] = [] + for (const [subject, items] of map.entries()) { + const sum = items.reduce((s, i) => s + i.percentage, 0) + groups.push({ + subject, + items: items.sort((a, b) => a.submittedAt.localeCompare(b.submittedAt)), + average: items.length > 0 ? sum / items.length : 0, + count: items.length, + }) + } + return groups.sort((a, b) => b.average - a.average) +} + +/** + * 成绩详情视图:按科目分组展示,每个科目显示平均分、趋势、最近成绩。 + * 用于详情页 grades tab,提供单科分析能力。 + */ +export function ChildGradeDetail({ grades }: { grades: StudentDashboardGradeProps }) { + if (grades.trend.length === 0) { + return ( + + ) + } + + const groups = groupBySubject(grades.trend) + const overallAvg = + grades.trend.reduce((s, i) => s + i.percentage, 0) / grades.trend.length + + return ( +
+ + + + + + Overall Average + + {Math.round(overallAvg)}% + + + +
+ {groups.length} {groups.length === 1 ? "subject" : "subjects"} · {grades.trend.length}{" "} + graded {grades.trend.length === 1 ? "assignment" : "assignments"} +
+
+
+ + {groups.map((group) => { + const percentages = group.items.map((i) => i.percentage) + const trend = computeTrend(percentages) + const latest = group.items[group.items.length - 1] + const best = Math.max(...percentages) + return ( + + + + {group.subject} +
+ + Avg {Math.round(group.average)}% + + +
+
+
+ +
+
+
Latest
+
+ {latest ? `${Math.round(latest.percentage)}%` : "-"} +
+
+
+
Best
+
{Math.round(best)}%
+
+
+
Count
+
{group.count}
+
+
+ +
+
+ Recent Grades +
+ {group.items.slice(-3).reverse().map((item) => ( +
+
+
+ {item.assignmentTitle} +
+
+ {formatDate(item.submittedAt)} +
+
+ + {item.score}/{item.maxScore} + +
+ ))} +
+
+
+ ) + })} +
+ ) +} diff --git a/src/modules/parent/components/child-grade-summary.tsx b/src/modules/parent/components/child-grade-summary.tsx index ba18dcb..d80b37f 100644 --- a/src/modules/parent/components/child-grade-summary.tsx +++ b/src/modules/parent/components/child-grade-summary.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react" import Link from "next/link" -import { BarChart3, Trophy } from "lucide-react" +import { BarChart3, TrendingDown, TrendingUp, Minus, Trophy } from "lucide-react" import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" import { TrendLineChart } from "@/shared/components/charts/trend-line-chart" @@ -10,6 +10,30 @@ import { Badge } from "@/shared/components/ui/badge" import { formatDate } from "@/shared/lib/utils" import type { StudentDashboardGradeProps } from "@/modules/homework/types" +type TrendDirection = "up" | "down" | "flat" | "unknown" + +const computeTrend = (trend: { percentage: number }[]): TrendDirection => { + if (trend.length < 2) return "unknown" + const latest = trend[trend.length - 1].percentage + const prev = trend[trend.length - 2].percentage + if (latest > prev) return "up" + if (latest < prev) return "down" + return "flat" +} + +const TrendIcon = ({ direction }: { direction: TrendDirection }) => { + if (direction === "up") { + return + } + if (direction === "down") { + return + } + if (direction === "flat") { + return + } + return null +} + export function ChildGradeSummary({ grades, childId, @@ -22,14 +46,16 @@ export function ChildGradeSummary({ const hasGradeTrend = grades.trend.length > 0 const ranking = grades.ranking const latestGrade = grades.trend[grades.trend.length - 1] + const trendDirection = computeTrend(grades.trend) const chartData = useMemo( () => - grades.trend.map((item) => ({ + grades.trend.map((item, idx) => ({ title: item.assignmentTitle, score: Math.round(item.percentage), fullTitle: item.assignmentTitle, date: formatDate(item.submittedAt), + index: idx + 1, rawScore: item.score, maxScore: item.maxScore, })), @@ -47,15 +73,21 @@ export function ChildGradeSummary({ emptyDescription="Finish and submit assignments to see score trend." emptyClassName="h-48" > -
+
- + Latest Score
-
- {latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"} +
+
+ {latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"} +
+ {hasGradeTrend ? : null}
{latestGrade ? (
@@ -65,13 +97,17 @@ export function ChildGradeSummary({
- + Class Rank
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
- {ranking ?
Current position
: null} + {ranking ? ( +
+ Top {Math.ceil((ranking.rank / ranking.classSize) * 100)}% +
+ ) : null}
@@ -87,7 +123,7 @@ export function ChildGradeSummary({ activeDotRadius: 5, }, ]} - xKey="date" + xKey="index" xTickFormatter={null} heightClassName="h-[160px]" margin={{ left: 8, right: 8, top: 8, bottom: 8 }} @@ -103,7 +139,7 @@ export function ChildGradeSummary({
{r.assignmentTitle}
diff --git a/src/modules/parent/components/child-schedule-card.tsx b/src/modules/parent/components/child-schedule-card.tsx index 1720f5a..7731483 100644 --- a/src/modules/parent/components/child-schedule-card.tsx +++ b/src/modules/parent/components/child-schedule-card.tsx @@ -3,22 +3,97 @@ import { CalendarDays, CalendarX } from "lucide-react" import { ScheduleList } from "@/shared/components/schedule/schedule-list" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" -import type { ChildScheduleItem } from "@/modules/parent/types" +import { cn } from "@/shared/lib/utils" +import type { ChildScheduleItem, ChildWeeklyScheduleItem } from "@/modules/parent/types" + +const WEEKDAY_LABELS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const + +const toWeekdayIndex = (w: 1 | 2 | 3 | 4 | 5 | 6 | 7): 0 | 1 | 2 | 3 | 4 | 5 | 6 => + (w - 1) as 0 | 1 | 2 | 3 | 4 | 5 | 6 + +const isToday = (w: 1 | 2 | 3 | 4 | 5 | 6 | 7): boolean => { + const today = new Date().getDay() + const normalizedToday = today === 0 ? 7 : today + return w === normalizedToday +} export function ChildScheduleCard({ items, childName, + weeklyItems, }: { items: ChildScheduleItem[] childName: string + /** 完整周课表(可选)。提供时切换为周课表视图。 */ + weeklyItems?: ChildWeeklyScheduleItem[] }) { const hasSchedule = items.length > 0 + const hasWeekly = weeklyItems && weeklyItems.length > 0 + + if (hasWeekly) { + const grouped: Record = {} + for (const item of weeklyItems!) { + const key = item.weekday + if (!grouped[key]) grouped[key] = [] + grouped[key].push(item) + } + const weekdays = Object.keys(grouped).map(Number).sort((a, b) => a - b) as Array<1 | 2 | 3 | 4 | 5 | 6 | 7> + + return ( + + + + + {childName}'s Weekly Schedule + + + + {weekdays.map((w) => { + const dayItems = grouped[w] + const today = isToday(w) + const label = WEEKDAY_LABELS[toWeekdayIndex(w)] + return ( +
+
+ {label} + {today ? ( + + Today + + ) : null} +
+ ({ + id: d.id, + classId: d.classId, + className: d.className, + course: d.course, + startTime: d.startTime, + endTime: d.endTime, + location: d.location ?? null, + }))} + variant="separator" + spacingClassName="space-y-2" + /> +
+ ) + })} +
+
+ ) + } return ( - + {childName}'s Today Schedule diff --git a/src/modules/parent/components/parent-attention-banner.tsx b/src/modules/parent/components/parent-attention-banner.tsx new file mode 100644 index 0000000..0d50615 --- /dev/null +++ b/src/modules/parent/components/parent-attention-banner.tsx @@ -0,0 +1,135 @@ +import Link from "next/link" +import { AlertTriangle, Bell, CalendarCheck, PenTool } from "lucide-react" + +import { Card } from "@/shared/components/ui/card" +import { cn } from "@/shared/lib/utils" +import type { ParentDashboardData } from "@/modules/parent/types" + +type AttentionItem = { + id: string + label: string + count: number + href: string + variant: "danger" | "warning" | "info" + icon: typeof AlertTriangle +} + +const buildAttentionItems = (data: ParentDashboardData): AttentionItem[] => { + const items: AttentionItem[] = [] + + const totalOverdue = data.children.reduce((sum, c) => sum + c.homeworkSummary.overdueCount, 0) + if (totalOverdue > 0) { + // 找到第一个有逾期作业的子女,直接跳转其详情页 homework tab + const childWithOverdue = data.children.find((c) => c.homeworkSummary.overdueCount > 0) + items.push({ + id: "overdue-homework", + label: "Overdue homework", + count: totalOverdue, + href: childWithOverdue + ? `/parent/children/${childWithOverdue.basicInfo.id}?tab=homework` + : "/parent/dashboard", + variant: "danger", + icon: PenTool, + }) + } + + const totalPending = data.children.reduce((sum, c) => sum + c.homeworkSummary.pendingCount, 0) + if (totalPending > 0) { + const childWithPending = data.children.find((c) => c.homeworkSummary.pendingCount > 0) + items.push({ + id: "pending-homework", + label: "Pending homework", + count: totalPending, + href: childWithPending + ? `/parent/children/${childWithPending.basicInfo.id}?tab=homework` + : "/parent/dashboard", + variant: "warning", + icon: PenTool, + }) + } + + items.push({ + id: "attendance", + label: "Attendance", + count: data.children.length, + href: "/parent/attendance", + variant: "info", + icon: CalendarCheck, + }) + + items.push({ + id: "announcements", + label: "Announcements", + count: 0, + href: "/announcements", + variant: "info", + icon: Bell, + }) + + return items +} + +const VARIANT_STYLES: Record = { + danger: "border-destructive/30 bg-destructive/5 text-destructive", + warning: "border-warning/30 bg-warning/5 text-warning-foreground", + info: "border-border bg-muted/40 text-foreground", +} + +/** + * 仪表盘顶部"需要关注"横幅。 + * 聚合所有子女的异常项(逾期作业、待办、考勤、公告),让家长一眼定位异常。 + */ +export function ParentAttentionBanner({ data }: { data: ParentDashboardData }) { + const items = buildAttentionItems(data) + const hasUrgent = items.some((i) => i.variant === "danger" && i.count > 0) + + if (items.length === 0) return null + + return ( + +
+
+ {hasUrgent ? ( + + ) : ( + + )} + + {hasUrgent ? "Needs attention" : "Nothing urgent"} + +
+
+ {items.map((item) => { + const Icon = item.icon + const isUrgent = item.variant === "danger" && item.count > 0 + return ( + + + + {item.label} + + {item.count} + + ) + })} +
+
+
+ ) +} diff --git a/src/modules/parent/components/parent-children-data-page.tsx b/src/modules/parent/components/parent-children-data-page.tsx index 08c7daa..e7ee8d0 100644 --- a/src/modules/parent/components/parent-children-data-page.tsx +++ b/src/modules/parent/components/parent-children-data-page.tsx @@ -15,6 +15,7 @@ export function ParentChildrenDataPage({ noRecordsDescription, items, renderItem, + headerExtra, }: { title: string description: string @@ -23,6 +24,8 @@ export function ParentChildrenDataPage({ noRecordsDescription: string items: T[] renderItem: (item: T) => React.ReactNode + /** 渲染在标题与列表之间的额外内容(如预警横幅)。 */ + headerExtra?: React.ReactNode }) { const hasItems = items.length > 0 @@ -33,6 +36,8 @@ export function ParentChildrenDataPage({

{description}

+ {headerExtra} + {!hasItems ? ( { + setIsExporting(true) + const result = await safeActionCall( + () => exportGradesAction({ studentId }), + { + onError: () => toast.error(`Failed to export grades for ${studentName}.`), + onFinally: () => setIsExporting(false), + } + ) + if (result?.success && result.data) { + try { + // 将 base64 buffer 转换为 Blob 并触发下载 + const binaryString = atob(result.data.buffer) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i += 1) { + bytes[i] = binaryString.charCodeAt(i) + } + const blob = new Blob([bytes], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = result.data.filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + toast.success(`Grades exported for ${studentName}.`) + } catch { + toast.error("Failed to download the export file.") + } + } else if (result) { + toast.error(result.message || `Failed to export grades for ${studentName}.`) + } + } + + if (isExporting) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/modules/parent/data-access.ts b/src/modules/parent/data-access.ts index 762da85..0ffedd8 100644 --- a/src/modules/parent/data-access.ts +++ b/src/modules/parent/data-access.ts @@ -1,7 +1,7 @@ import "server-only" import { cache } from "react" -import { and, asc, eq } from "drizzle-orm" +import { and, asc, eq, inArray } from "drizzle-orm" import { db } from "@/shared/db" import { parentStudentRelations } from "@/shared/db/schema" @@ -267,3 +267,19 @@ export const getChildNameList = cache( })) }, ) + +/** + * v4-P1-5: 批量查询多个学生的家长 userId 列表。 + * 用于通知场景:报告/成绩发布时同步通知所有相关家长。 + * 返回去重后的 parentId 数组。 + */ +export const getParentIdsByStudentIds = cache( + async (studentIds: string[]): Promise => { + if (studentIds.length === 0) return [] + const rows = await db + .select({ parentId: parentStudentRelations.parentId }) + .from(parentStudentRelations) + .where(inArray(parentStudentRelations.studentId, studentIds)) + return Array.from(new Set(rows.map((r) => r.parentId))) + }, +)