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
This commit is contained in:
SpecialX
2026-06-23 17:37:49 +08:00
parent 95145cd03b
commit 1abf58c0b6
9 changed files with 632 additions and 31 deletions

View File

@@ -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 <TrendingUp className="h-3 w-3 text-emerald-600" aria-label="improving" />
}
if (direction === "down") {
return <TrendingDown className="h-3 w-3 text-destructive" aria-label="declining" />
}
if (direction === "flat") {
return <Minus className="h-3 w-3 text-muted-foreground" aria-label="stable" />
}
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 (
<Link
href={`/parent/children/${basicInfo.id}`}
aria-label={`View ${childName}'s details`}
prefetch
aria-label={`View ${childName}'s details${hasOverdue ? `, ${homeworkSummary.overdueCount} overdue homework` : ""}`}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md"
>
<Card className="hover:bg-muted/50 transition-colors h-full">
<Card
className={cn(
"hover:bg-muted/50 transition-colors h-full",
hasOverdue && "border-destructive/40 bg-destructive/5",
)}
>
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
<Avatar className="h-12 w-12">
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
@@ -37,28 +77,45 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
</div>
</div>
{hasOverdue ? (
<AlertTriangle
className="h-5 w-5 text-destructive shrink-0"
aria-label={`${homeworkSummary.overdueCount} overdue`}
/>
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<PenTool className="h-3 w-3" />
<PenTool className="h-3 w-3" aria-hidden />
Pending
</div>
<div className="text-lg font-semibold tabular-nums">
{homeworkSummary.pendingCount}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<TriangleAlert className="h-3 w-3" />
<div
className={cn(
"rounded-md p-2",
hasOverdue ? "bg-destructive/10" : "bg-muted/50",
)}
>
<div
className={cn(
"text-xs flex items-center justify-center gap-1",
hasOverdue ? "text-destructive" : "text-muted-foreground",
)}
>
<AlertTriangle className="h-3 w-3" aria-hidden />
Overdue
</div>
<div
className={cn(
"text-lg font-semibold tabular-nums",
homeworkSummary.overdueCount > 0 && "text-destructive",
"text-lg font-bold tabular-nums",
hasOverdue && "text-destructive",
)}
>
{homeworkSummary.overdueCount}
@@ -66,7 +123,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<GraduationCap className="h-3 w-3" />
<GraduationCap className="h-3 w-3" aria-hidden />
Avg
</div>
<div className="text-lg font-semibold tabular-nums">
@@ -80,6 +137,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
<span className="font-medium text-foreground tabular-nums shrink-0">
{latestGrade.score}/{latestGrade.maxScore}
</span>
<TrendIcon direction={trendDirection} />
<span className="truncate">({latestGrade.assignmentTitle})</span>
</div>
) : null}

View File

@@ -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 (
<div className="space-y-4">
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
<Link href="/parent/dashboard">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/parent/dashboard">Parent Dashboard</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{childName}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2 md:ml-0">
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Link>
</Button>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
@@ -40,7 +63,7 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) {
) : null}
{basicInfo.gradeName ? (
<span className="flex items-center gap-1">
<GraduationCap className="h-3 w-3" />
<GraduationCap className="h-3 w-3" aria-hidden />
{basicInfo.gradeName}
</span>
) : null}

View File

@@ -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 <TrendingUp className="h-3 w-3 text-emerald-600" aria-label="improving" />
}
if (direction === "down") {
return <TrendingDown className="h-3 w-3 text-destructive" aria-label="declining" />
}
if (direction === "flat") {
return <Minus className="h-3 w-3 text-muted-foreground" aria-label="stable" />
}
return null
}
type SubjectGroup = {
subject: string
items: StudentDashboardGradeProps["trend"]
average: number
count: number
}
const groupBySubject = (
trend: StudentDashboardGradeProps["trend"],
): SubjectGroup[] => {
const map = new Map<string, StudentDashboardGradeProps["trend"]>()
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 (
<EmptyState
icon={BarChart3}
title="No graded work yet"
description="Finish and submit assignments to see score analysis."
className="border-none shadow-none h-48"
/>
)
}
const groups = groupBySubject(grades.trend)
const overallAvg =
grades.trend.reduce((s, i) => s + i.percentage, 0) / grades.trend.length
return (
<div className="space-y-4" aria-label="Grade analysis by subject">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-base">
<span className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" aria-hidden />
Overall Average
</span>
<span className="tabular-nums">{Math.round(overallAvg)}%</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xs text-muted-foreground">
{groups.length} {groups.length === 1 ? "subject" : "subjects"} · {grades.trend.length}{" "}
graded {grades.trend.length === 1 ? "assignment" : "assignments"}
</div>
</CardContent>
</Card>
{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 (
<Card key={group.subject}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-base">
<span>{group.subject}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="tabular-nums">
Avg {Math.round(group.average)}%
</Badge>
<TrendIcon direction={trend} />
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-md bg-muted/50 p-2">
<div className="text-muted-foreground">Latest</div>
<div className="font-semibold tabular-nums">
{latest ? `${Math.round(latest.percentage)}%` : "-"}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-muted-foreground">Best</div>
<div className="font-semibold tabular-nums">{Math.round(best)}%</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-muted-foreground">Count</div>
<div className="font-semibold tabular-nums">{group.count}</div>
</div>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium uppercase text-muted-foreground">
Recent Grades
</div>
{group.items.slice(-3).reverse().map((item) => (
<div
key={item.assignmentId}
className="flex min-h-[36px] items-center justify-between rounded-md border bg-card p-2"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{item.assignmentTitle}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(item.submittedAt)}
</div>
</div>
<Badge variant="secondary" className="tabular-nums shrink-0 ml-2">
{item.score}/{item.maxScore}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -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 <TrendingUp className="h-4 w-4 text-emerald-600" aria-label="improving" />
}
if (direction === "down") {
return <TrendingDown className="h-4 w-4 text-destructive" aria-label="declining" />
}
if (direction === "flat") {
return <Minus className="h-4 w-4 text-muted-foreground" aria-label="stable" />
}
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,16 +73,22 @@ export function ChildGradeSummary({
emptyDescription="Finish and submit assignments to see score trend."
emptyClassName="h-48"
>
<div className="space-y-4">
<div
className="space-y-4"
aria-label={`Grade trend chart for ${childName}, ${grades.trend.length} records`}
>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted/50 p-3">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
<BarChart3 className="h-3 w-3" aria-hidden />
Latest Score
</div>
<div className="flex items-center gap-2">
<div className="text-xl font-semibold tabular-nums">
{latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
</div>
{hasGradeTrend ? <TrendIcon direction={trendDirection} /> : null}
</div>
{latestGrade ? (
<div className="text-xs text-muted-foreground tabular-nums">
{latestGrade.score}/{latestGrade.maxScore}
@@ -65,13 +97,17 @@ export function ChildGradeSummary({
</div>
<div className="rounded-md bg-muted/50 p-3">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Trophy className="h-3 w-3" />
<Trophy className="h-3 w-3" aria-hidden />
Class Rank
</div>
<div className="text-xl font-semibold tabular-nums">
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
</div>
{ranking ? <div className="text-xs text-muted-foreground">Current position</div> : null}
{ranking ? (
<div className="text-xs text-muted-foreground">
Top {Math.ceil((ranking.rank / ranking.classSize) * 100)}%
</div>
) : null}
</div>
</div>
@@ -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({
<Link
key={r.assignmentId}
href={`/parent/children/${childId}?tab=grades`}
className="flex items-center justify-between rounded-md border bg-card p-2 hover:bg-muted/50 transition-colors"
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-2 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{r.assignmentTitle}</div>

View File

@@ -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<number, ChildWeeklyScheduleItem[]> = {}
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Weekly Schedule
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{weekdays.map((w) => {
const dayItems = grouped[w]
const today = isToday(w)
const label = WEEKDAY_LABELS[toWeekdayIndex(w)]
return (
<div
key={w}
className={cn(
"rounded-md border p-3",
today && "border-primary/40 bg-primary/5",
)}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">{label}</span>
{today ? (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-medium text-primary">
Today
</span>
) : null}
</div>
<ScheduleList
items={dayItems.map((d) => ({
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"
/>
</div>
)
})}
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Today Schedule
</CardTitle>
</CardHeader>

View File

@@ -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<AttentionItem["variant"], string> = {
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 (
<Card
className={cn(
"p-4",
hasUrgent && "border-destructive/40 bg-destructive/5",
)}
aria-label="Items needing attention"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
{hasUrgent ? (
<AlertTriangle className="h-4 w-4 text-destructive" aria-hidden />
) : (
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden />
)}
<span className="text-sm font-medium">
{hasUrgent ? "Needs attention" : "Nothing urgent"}
</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
{items.map((item) => {
const Icon = item.icon
const isUrgent = item.variant === "danger" && item.count > 0
return (
<Link
key={item.id}
href={item.href}
className={cn(
"inline-flex h-9 min-w-[120px] items-center justify-between gap-2 rounded-md border px-3 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
VARIANT_STYLES[item.variant],
isUrgent && "font-semibold",
)}
aria-label={`${item.label}: ${item.count}`}
>
<span className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5" aria-hidden />
{item.label}
</span>
<span className="tabular-nums">{item.count}</span>
</Link>
)
})}
</div>
</div>
</Card>
)
}

View File

@@ -15,6 +15,7 @@ export function ParentChildrenDataPage<T>({
noRecordsDescription,
items,
renderItem,
headerExtra,
}: {
title: string
description: string
@@ -23,6 +24,8 @@ export function ParentChildrenDataPage<T>({
noRecordsDescription: string
items: T[]
renderItem: (item: T) => React.ReactNode
/** 渲染在标题与列表之间的额外内容(如预警横幅)。 */
headerExtra?: React.ReactNode
}) {
const hasItems = items.length > 0
@@ -33,6 +36,8 @@ export function ParentChildrenDataPage<T>({
<p className="text-muted-foreground">{description}</p>
</div>
{headerExtra}
{!hasItems ? (
<EmptyState
title={noRecordsTitle}

View File

@@ -0,0 +1,85 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { Download, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { safeActionCall } from "@/shared/lib/action-utils"
import { exportGradesAction } from "@/modules/grades/actions"
/**
* 家长视角的成绩导出按钮。
* v4-P1-12: 接入 exportGradesAction支持按 studentId 导出子女成绩。
*/
export function ParentExportButton({
studentId,
studentName,
variant = "outline",
size = "sm",
}: {
studentId: string
studentName: string
variant?: "default" | "outline" | "secondary" | "ghost"
size?: "default" | "sm" | "lg" | "icon"
}) {
const [isExporting, setIsExporting] = useState(false)
const handleExport = async () => {
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 (
<Button variant={variant} size={size} disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Exporting...
</Button>
)
}
return (
<Button
variant={variant}
size={size}
onClick={handleExport}
aria-label={`Export ${studentName}'s grades`}
>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
)
}

View File

@@ -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<string[]> => {
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)))
},
)