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:
@@ -1,5 +1,13 @@
|
|||||||
import Link from "next/link"
|
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 { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
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 { cn, getInitials } from "@/shared/lib/utils"
|
||||||
import type { ChildDashboardData } from "@/modules/parent/types"
|
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 }) {
|
export function ChildCard({ child }: { child: ChildDashboardData }) {
|
||||||
const { basicInfo, homeworkSummary, gradeTrend } = child
|
const { basicInfo, homeworkSummary, gradeTrend } = child
|
||||||
const ranking = gradeTrend.ranking
|
const ranking = gradeTrend.ranking
|
||||||
const latestGrade = gradeTrend.recent[0]
|
const latestGrade = gradeTrend.recent[0]
|
||||||
const childName = basicInfo.name ?? "Child"
|
const childName = basicInfo.name ?? "Child"
|
||||||
|
const hasOverdue = homeworkSummary.overdueCount > 0
|
||||||
|
const trendDirection = computeTrend(gradeTrend.trend)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/parent/children/${basicInfo.id}`}
|
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"
|
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">
|
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
|
||||||
<Avatar className="h-12 w-12">
|
<Avatar className="h-12 w-12">
|
||||||
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
|
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
|
||||||
@@ -37,28 +77,45 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
|
|||||||
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
|
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
<div className="rounded-md bg-muted/50 p-2">
|
<div className="rounded-md bg-muted/50 p-2">
|
||||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
<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
|
Pending
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold tabular-nums">
|
<div className="text-lg font-semibold tabular-nums">
|
||||||
{homeworkSummary.pendingCount}
|
{homeworkSummary.pendingCount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/50 p-2">
|
<div
|
||||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
className={cn(
|
||||||
<TriangleAlert className="h-3 w-3" />
|
"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
|
Overdue
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold tabular-nums",
|
"text-lg font-bold tabular-nums",
|
||||||
homeworkSummary.overdueCount > 0 && "text-destructive",
|
hasOverdue && "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{homeworkSummary.overdueCount}
|
{homeworkSummary.overdueCount}
|
||||||
@@ -66,7 +123,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/50 p-2">
|
<div className="rounded-md bg-muted/50 p-2">
|
||||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
<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
|
Avg
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold tabular-nums">
|
<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">
|
<span className="font-medium text-foreground tabular-nums shrink-0">
|
||||||
{latestGrade.score}/{latestGrade.maxScore}
|
{latestGrade.score}/{latestGrade.maxScore}
|
||||||
</span>
|
</span>
|
||||||
|
<TrendIcon direction={trendDirection} />
|
||||||
<span className="truncate">({latestGrade.assignmentTitle})</span>
|
<span className="truncate">({latestGrade.assignmentTitle})</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { ArrowLeft, GraduationCap } from "lucide-react"
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
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 { getInitials } from "@/shared/lib/utils"
|
||||||
import type { ChildDashboardData } from "@/modules/parent/types"
|
import type { ChildDashboardData } from "@/modules/parent/types"
|
||||||
|
|
||||||
@@ -21,12 +29,27 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<Link href="/parent/dashboard">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar className="h-16 w-16">
|
<Avatar className="h-16 w-16">
|
||||||
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
|
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
|
||||||
@@ -40,7 +63,7 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{basicInfo.gradeName ? (
|
{basicInfo.gradeName ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<GraduationCap className="h-3 w-3" />
|
<GraduationCap className="h-3 w-3" aria-hidden />
|
||||||
{basicInfo.gradeName}
|
{basicInfo.gradeName}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
168
src/modules/parent/components/child-grade-detail.tsx
Normal file
168
src/modules/parent/components/child-grade-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import Link from "next/link"
|
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 { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
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 { formatDate } from "@/shared/lib/utils"
|
||||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
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({
|
export function ChildGradeSummary({
|
||||||
grades,
|
grades,
|
||||||
childId,
|
childId,
|
||||||
@@ -22,14 +46,16 @@ export function ChildGradeSummary({
|
|||||||
const hasGradeTrend = grades.trend.length > 0
|
const hasGradeTrend = grades.trend.length > 0
|
||||||
const ranking = grades.ranking
|
const ranking = grades.ranking
|
||||||
const latestGrade = grades.trend[grades.trend.length - 1]
|
const latestGrade = grades.trend[grades.trend.length - 1]
|
||||||
|
const trendDirection = computeTrend(grades.trend)
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
grades.trend.map((item) => ({
|
grades.trend.map((item, idx) => ({
|
||||||
title: item.assignmentTitle,
|
title: item.assignmentTitle,
|
||||||
score: Math.round(item.percentage),
|
score: Math.round(item.percentage),
|
||||||
fullTitle: item.assignmentTitle,
|
fullTitle: item.assignmentTitle,
|
||||||
date: formatDate(item.submittedAt),
|
date: formatDate(item.submittedAt),
|
||||||
|
index: idx + 1,
|
||||||
rawScore: item.score,
|
rawScore: item.score,
|
||||||
maxScore: item.maxScore,
|
maxScore: item.maxScore,
|
||||||
})),
|
})),
|
||||||
@@ -47,16 +73,22 @@ export function ChildGradeSummary({
|
|||||||
emptyDescription="Finish and submit assignments to see score trend."
|
emptyDescription="Finish and submit assignments to see score trend."
|
||||||
emptyClassName="h-48"
|
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="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-md bg-muted/50 p-3">
|
<div className="rounded-md bg-muted/50 p-3">
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
<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
|
Latest Score
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-xl font-semibold tabular-nums">
|
<div className="text-xl font-semibold tabular-nums">
|
||||||
{latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
|
{latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
|
||||||
</div>
|
</div>
|
||||||
|
{hasGradeTrend ? <TrendIcon direction={trendDirection} /> : null}
|
||||||
|
</div>
|
||||||
{latestGrade ? (
|
{latestGrade ? (
|
||||||
<div className="text-xs text-muted-foreground tabular-nums">
|
<div className="text-xs text-muted-foreground tabular-nums">
|
||||||
{latestGrade.score}/{latestGrade.maxScore}
|
{latestGrade.score}/{latestGrade.maxScore}
|
||||||
@@ -65,13 +97,17 @@ export function ChildGradeSummary({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/50 p-3">
|
<div className="rounded-md bg-muted/50 p-3">
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
<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
|
Class Rank
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-semibold tabular-nums">
|
<div className="text-xl font-semibold tabular-nums">
|
||||||
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +123,7 @@ export function ChildGradeSummary({
|
|||||||
activeDotRadius: 5,
|
activeDotRadius: 5,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
xKey="date"
|
xKey="index"
|
||||||
xTickFormatter={null}
|
xTickFormatter={null}
|
||||||
heightClassName="h-[160px]"
|
heightClassName="h-[160px]"
|
||||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||||
@@ -103,7 +139,7 @@ export function ChildGradeSummary({
|
|||||||
<Link
|
<Link
|
||||||
key={r.assignmentId}
|
key={r.assignmentId}
|
||||||
href={`/parent/children/${childId}?tab=grades`}
|
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="min-w-0 flex-1">
|
||||||
<div className="font-medium text-sm truncate">{r.assignmentTitle}</div>
|
<div className="font-medium text-sm truncate">{r.assignmentTitle}</div>
|
||||||
|
|||||||
@@ -3,22 +3,97 @@ import { CalendarDays, CalendarX } from "lucide-react"
|
|||||||
import { ScheduleList } from "@/shared/components/schedule/schedule-list"
|
import { ScheduleList } from "@/shared/components/schedule/schedule-list"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
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({
|
export function ChildScheduleCard({
|
||||||
items,
|
items,
|
||||||
childName,
|
childName,
|
||||||
|
weeklyItems,
|
||||||
}: {
|
}: {
|
||||||
items: ChildScheduleItem[]
|
items: ChildScheduleItem[]
|
||||||
childName: string
|
childName: string
|
||||||
|
/** 完整周课表(可选)。提供时切换为周课表视图。 */
|
||||||
|
weeklyItems?: ChildWeeklyScheduleItem[]
|
||||||
}) {
|
}) {
|
||||||
const hasSchedule = items.length > 0
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<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}'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}'s Today Schedule
|
{childName}'s Today Schedule
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
135
src/modules/parent/components/parent-attention-banner.tsx
Normal file
135
src/modules/parent/components/parent-attention-banner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export function ParentChildrenDataPage<T>({
|
|||||||
noRecordsDescription,
|
noRecordsDescription,
|
||||||
items,
|
items,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
headerExtra,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -23,6 +24,8 @@ export function ParentChildrenDataPage<T>({
|
|||||||
noRecordsDescription: string
|
noRecordsDescription: string
|
||||||
items: T[]
|
items: T[]
|
||||||
renderItem: (item: T) => React.ReactNode
|
renderItem: (item: T) => React.ReactNode
|
||||||
|
/** 渲染在标题与列表之间的额外内容(如预警横幅)。 */
|
||||||
|
headerExtra?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const hasItems = items.length > 0
|
const hasItems = items.length > 0
|
||||||
|
|
||||||
@@ -33,6 +36,8 @@ export function ParentChildrenDataPage<T>({
|
|||||||
<p className="text-muted-foreground">{description}</p>
|
<p className="text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{headerExtra}
|
||||||
|
|
||||||
{!hasItems ? (
|
{!hasItems ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={noRecordsTitle}
|
title={noRecordsTitle}
|
||||||
|
|||||||
85
src/modules/parent/components/parent-export-button.tsx
Normal file
85
src/modules/parent/components/parent-export-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
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 { db } from "@/shared/db"
|
||||||
import { parentStudentRelations } from "@/shared/db/schema"
|
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)))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user