feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
Mail,
|
||||
Stethoscope,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
@@ -26,7 +27,7 @@ export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "sch
|
||||
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
|
||||
|
||||
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
|
||||
typeof v === "string" && (VALID_TABS as string[]).includes(v)
|
||||
typeof v === "string" && VALID_TABS.some((tab) => tab === v)
|
||||
|
||||
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
|
||||
isTab(v) ? v : "overview"
|
||||
@@ -40,40 +41,47 @@ export function ChildDetailPanel({
|
||||
initialTab?: string
|
||||
siblingSwitcher?: React.ReactNode
|
||||
}) {
|
||||
const t = useTranslations("parent")
|
||||
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
|
||||
const childName = basicInfo.name ?? "Child"
|
||||
const childName = basicInfo.name ?? t("childDetail.defaultName")
|
||||
|
||||
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ id: "overview" as const, label: "Overview", icon: ClipboardList },
|
||||
{ id: "homework" as const, label: "Homework", icon: ClipboardList },
|
||||
{ id: "grades" as const, label: "Grades", icon: BarChart3 },
|
||||
{ id: "exams" as const, label: "Exams", icon: GraduationCap },
|
||||
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays },
|
||||
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays },
|
||||
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope },
|
||||
{ id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
|
||||
{ id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
|
||||
{ id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
|
||||
{ id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
|
||||
{ id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
|
||||
{ id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
|
||||
{ id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{siblingSwitcher}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
if (isTab(v)) setTab(v)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="w-full justify-start">
|
||||
{tabs.map((t) => (
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-1.5"
|
||||
aria-label={`${t.label} tab`}
|
||||
aria-label={t("childDetail.tabAriaLabel", { label: tab.label })}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
<tab.icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
@@ -108,7 +116,7 @@ export function ChildDetailPanel({
|
||||
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
|
||||
Subject Analysis
|
||||
{t("childDetail.subjectAnalysis")}
|
||||
</h3>
|
||||
<ChildGradeDetail grades={gradeTrend} />
|
||||
</div>
|
||||
@@ -134,14 +142,16 @@ export function ChildDetailPanel({
|
||||
<TabsContent value="attendance" className="mt-6">
|
||||
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Attendance details are available on the{" "}
|
||||
<a
|
||||
href="/parent/attendance"
|
||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Attendance page
|
||||
</a>
|
||||
.
|
||||
{t.rich("childDetail.attendanceHint", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href="/parent/attendance"
|
||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -149,7 +159,7 @@ export function ChildDetailPanel({
|
||||
<TabsContent value="diagnostic" className="mt-6">
|
||||
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Diagnostic reports will be available here once published by the school.
|
||||
{t("childDetail.diagnosticHint")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -157,9 +167,12 @@ export function ChildDetailPanel({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2">
|
||||
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
|
||||
<a
|
||||
href={`/messages?studentId=${basicInfo.id}`}
|
||||
aria-label={t("childDetail.contactTeacherAria", { name: childName })}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Contact Teacher
|
||||
{t("childDetail.contactTeacher")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -175,21 +188,24 @@ export function SiblingSwitcher({
|
||||
current: { id: string; name: string | null }
|
||||
siblings: Array<{ id: string; name: string | null }>
|
||||
}) {
|
||||
const t = useTranslations("parent")
|
||||
if (siblings.length <= 1) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
|
||||
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("childDetail.switchChild")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{siblings.map((s) => {
|
||||
const isActive = s.id === current.id
|
||||
const label = s.name ?? "Child"
|
||||
const label = s.name ?? t("childDetail.defaultName")
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`/parent/children/${s.id}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={`View ${label}'s details`}
|
||||
aria-label={t("childDetail.viewDetailsAria", { name: label })}
|
||||
className={cn(
|
||||
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { GraduationCap, TrendingUp, Award, BookOpen, ChevronRight } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -32,14 +33,19 @@ interface ChildExamDetailProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-11: 家长端子女考试详情视图
|
||||
* V3-11 / P3-4: 家长端子女考试详情视图
|
||||
*
|
||||
* 对标智学网家长端,展示:
|
||||
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
|
||||
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
|
||||
* - 成绩趋势可视化
|
||||
*
|
||||
* P3-4 改进:
|
||||
* - 所有用户可见文本使用 i18n 翻译键(next-intl)
|
||||
* - 触控目标 ≥ 44px(min-h-[44px])
|
||||
* - 语义化标签 + ARIA 属性
|
||||
*/
|
||||
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
|
||||
const t = useTranslations("examHomework")
|
||||
const hasResults = examResults.length > 0
|
||||
|
||||
const examCount = examResults.length
|
||||
@@ -60,33 +66,39 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<GraduationCap className="h-5 w-5 text-primary" />
|
||||
<GraduationCap className="h-5 w-5 text-primary" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Exams Taken</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("homework.parentExam.examsTaken")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
||||
<TrendingUp className="h-5 w-5 text-blue-500" />
|
||||
<TrendingUp className="h-5 w-5 text-blue-500" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Average Score</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("homework.parentExam.averageScore")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||
<Award className="h-5 w-5 text-green-500" />
|
||||
<Award className="h-5 w-5 text-green-500" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Best Score</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("homework.parentExam.bestScore")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -97,54 +109,60 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{childName}'s Exam Results
|
||||
{t("homework.parentExam.examResults", { name: childName })}
|
||||
</CardTitle>
|
||||
<CardDescription>Recent exam scores and performance trends</CardDescription>
|
||||
<CardDescription>
|
||||
{t("homework.parentExam.examResultsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasResults ? (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title="No exam results"
|
||||
description="Exam results will appear here once available."
|
||||
title={t("homework.parentExam.noResults")}
|
||||
description={t("homework.parentExam.noResultsHint")}
|
||||
className="border-none h-48"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<ul className="space-y-3" aria-label={t("homework.parentExam.examResults", { name: childName })}>
|
||||
{examResults.map((r) => {
|
||||
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
|
||||
const isPass = scoreRate >= 60
|
||||
return (
|
||||
<Link
|
||||
key={r.submissionId}
|
||||
href={`/parent/children/${childId}?tab=grades`}
|
||||
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 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 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm truncate">{r.examTitle}</div>
|
||||
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
|
||||
{isPass ? "Pass" : "Below 60%"}
|
||||
</Badge>
|
||||
<li key={r.submissionId}>
|
||||
<Link
|
||||
href={`/parent/children/${childId}?tab=grades`}
|
||||
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 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 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm truncate">{r.examTitle}</div>
|
||||
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
|
||||
{isPass ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{r.submittedAt ? (
|
||||
<span>{formatDate(r.submittedAt)}</span>
|
||||
) : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span className="tabular-nums">
|
||||
{r.score} / {r.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={scoreRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{r.submittedAt ? (
|
||||
<span>{formatDate(r.submittedAt)}</span>
|
||||
) : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span className="tabular-nums">
|
||||
{r.score} / {r.maxScore}
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{scoreRate.toFixed(0)}%
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
</div>
|
||||
<Progress value={scoreRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
|
||||
{scoreRate.toFixed(0)}%
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useState, useRef, type KeyboardEvent } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ATTENDANCE_STATUS_DOT_COLORS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "@/modules/attendance/constants"
|
||||
} from "@/shared/constants/attendance-status"
|
||||
import type {
|
||||
ParentAttendanceListItem,
|
||||
ParentAttendanceStatus,
|
||||
@@ -86,6 +86,7 @@ export function ParentAttendanceCalendar({
|
||||
summary: ParentStudentAttendanceSummary
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const locale = useLocale()
|
||||
const now = new Date()
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||
@@ -99,7 +100,7 @@ export function ParentAttendanceCalendar({
|
||||
}
|
||||
|
||||
const days = buildCalendarDays(viewYear, viewMonth)
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
@@ -50,15 +48,17 @@ const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长考勤页顶部的出勤率汇总卡片。
|
||||
* 家长考勤页顶部的出勤率汇总卡片(RSC)。
|
||||
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
||||
*
|
||||
* P2-8 修复:从 client component 降级为 RSC,使用 `getTranslations`。
|
||||
*/
|
||||
export function ParentAttendanceRateCard({
|
||||
export async function ParentAttendanceRateCard({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const t = await getTranslations("attendance")
|
||||
const stats = aggregateStats(summaries)
|
||||
if (stats.totalStudents === 0) return null
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Phone } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
@@ -14,8 +12,11 @@ type Warning = {
|
||||
severity: "high" | "medium"
|
||||
}
|
||||
|
||||
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
|
||||
type Translator = ReturnType<typeof useTranslations>
|
||||
/** 翻译函数类型(与 useTranslations / getTranslations 返回值兼容) */
|
||||
type Translator = (
|
||||
key: string,
|
||||
values?: Record<string, string | number | Date>,
|
||||
) => string
|
||||
|
||||
/**
|
||||
* 构建考勤异常预警列表(纯函数,便于测试)。
|
||||
@@ -67,15 +68,17 @@ export function buildWarnings(
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤异常预警横幅。
|
||||
* 家长视角的考勤异常预警横幅(RSC)。
|
||||
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
||||
*
|
||||
* P2-8 修复:从 client component 降级为 RSC,使用 `getTranslations`。
|
||||
*/
|
||||
export function ParentAttendanceWarning({
|
||||
export async function ParentAttendanceWarning({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const t = await getTranslations("attendance")
|
||||
const warnings = buildWarnings(summaries, t)
|
||||
if (warnings.length === 0) return null
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import {
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
GraduationCap,
|
||||
Megaphone,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { ChildCard } from "./child-card"
|
||||
import { ParentAttentionBanner } from "./parent-attention-banner"
|
||||
|
||||
export async function ParentDashboard({ data }: { data: ParentDashboardData }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const { parentName, children } = data
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ href: "/parent/grades", label: t("quickActions.grades"), icon: GraduationCap },
|
||||
{ href: "/parent/attendance", label: t("quickActions.attendance"), icon: CalendarCheck },
|
||||
{ href: "/announcements", label: t("quickActions.announcements"), icon: Megaphone },
|
||||
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(`greeting.${greetingKey}`)}
|
||||
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<ParentAttentionBanner data={data} />
|
||||
|
||||
<nav
|
||||
aria-label={t("quickActions.announcements")}
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
{QUICK_ENTRIES.map((entry) => (
|
||||
<Link
|
||||
key={entry.href}
|
||||
href={entry.href}
|
||||
className="group"
|
||||
aria-label={entry.label}
|
||||
>
|
||||
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<entry.icon
|
||||
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" aria-hidden />
|
||||
<span>
|
||||
{t("badge.childrenLinked", { count: children.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* 移动端水平滑动卡片,桌面端网格布局 */}
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
|
||||
aria-label={t("title.parent")}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
|
||||
<ChildCard child={child} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{children.map((child) => (
|
||||
<ChildCard key={child.basicInfo.id} child={child} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("empty.noChildren")}
|
||||
description={t("empty.noChildrenDesc")}
|
||||
className="border-none shadow-none"
|
||||
action={{
|
||||
label: t("empty.contactSupport"),
|
||||
href: "/messages",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
CalendarCheck,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Users,
|
||||
XCircle,
|
||||
Inbox,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatItem } from "@/shared/components/ui/stat-item"
|
||||
import {
|
||||
ATTENDANCE_STATUS_BADGE_VARIANTS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "@/shared/constants/attendance-status"
|
||||
|
||||
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||
|
||||
/**
|
||||
* 家长视角的单个子女考勤详情组件(统计卡片 + 最近记录表)。
|
||||
*
|
||||
* 解耦说明(P1-2 修复):
|
||||
* - 此组件替代原先从 `@/modules/attendance/components/student-attendance-view` 直接导入的 `StudentAttendanceView`。
|
||||
* - parent 模块不再依赖 attendance 模块的 UI 组件,仅依赖自身类型与 shared 常量。
|
||||
* - 数据通过 props 注入(由 parent page 通过 `AttendanceReadService` 接口获取后传入)。
|
||||
*/
|
||||
export function ParentStudentAttendanceDetail({
|
||||
summary,
|
||||
}: {
|
||||
summary: ParentStudentAttendanceSummary
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const { stats, recentRecords } = summary
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("list.columns.student")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("stats.totalRecords")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{stats.total === 0 ? (
|
||||
<EmptyState
|
||||
title={t("stats.noData")}
|
||||
description={t("stats.noDataDescription")}
|
||||
icon={CalendarCheck}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("title.teacherStats")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatItem
|
||||
label={t("stats.totalRecords")}
|
||||
value={stats.total}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label={t("stats.present")}
|
||||
value={stats.present}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label={t("stats.absent")}
|
||||
value={stats.absent}
|
||||
icon={<XCircle className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label={t("stats.late")}
|
||||
value={stats.late}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
/>
|
||||
<StatItem
|
||||
label={t("stats.attendanceRate")}
|
||||
value={`${stats.presentRate.toFixed(1)}%`}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{recentRecords.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.empty")}
|
||||
description={t("list.emptyDescription")}
|
||||
icon={Inbox}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("stats.recentRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("list.columns.date")}</TableHead>
|
||||
<TableHead>{t("list.columns.status")}</TableHead>
|
||||
<TableHead>{t("list.columns.remark")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentRecords.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.date}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]}
|
||||
className="capitalize"
|
||||
>
|
||||
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.remark ?? "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user