diff --git a/src/modules/dashboard/actions.ts b/src/modules/dashboard/actions.ts index d5b1977..610f3a2 100644 --- a/src/modules/dashboard/actions.ts +++ b/src/modules/dashboard/actions.ts @@ -8,10 +8,9 @@ import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClas import { getHomeworkAssignments, getHomeworkSubmissions, - getStudentDashboardGrades, - getStudentHomeworkAssignments, - getTeacherGradeTrends, } from "@/modules/homework/data-access" +import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student" +import { getStudentDashboardGrades, getTeacherGradeTrends } from "@/modules/homework/stats-service" import { getCurrentStudentUser, getUserBasicInfo } from "@/modules/users/data-access" import { getParentDashboardData } from "@/modules/parent/data-access" @@ -19,7 +18,6 @@ import { getAdminDashboardData } from "./data-access" import type { AdminDashboardData, StudentDashboardProps, - StudentTodayScheduleItem, TeacherDashboardData, } from "./types" import type { ParentDashboardData } from "@/modules/parent/types" @@ -82,7 +80,7 @@ export async function getTeacherDashboardAction(): Promise(schedule, todayWeekday) + const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday) const upcomingAssignments = sortUpcomingAssignments(assignments, 6) return { diff --git a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx index c5b301f..ee695ce 100644 --- a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx +++ b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx @@ -16,6 +16,7 @@ import { Button } from "@/shared/components/ui/button" import { Badge } from "@/shared/components/ui/badge" import { Skeleton } from "@/shared/components/ui/skeleton" import { DashboardSection } from "../dashboard-section" +import { DashboardTimeRangeFilter } from "../dashboard-time-range-filter" import type { AdminDashboardStreams } from "../../streams" import { AdminContentCard, @@ -62,10 +63,15 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS } /> - + + {/* L2: 时间范围筛选器 — 当前为 UI 占位,趋势数据接入后生效 */} +
+ +
+ {/* 快捷操作 — 纯静态,无需数据获取 */}
- +
- + - + - +
- + diff --git a/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx b/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx index c5c10d9..d8cc096 100644 --- a/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx +++ b/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx @@ -35,10 +35,10 @@ export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams: return (
- - - - + + + +
) } @@ -77,10 +77,10 @@ export function AdminContentCard({ t, streams }: { t: TranslationFunction; strea {t("sections.content")} - } /> - } /> - } /> - } /> + } href="/admin/textbooks" /> + } href="/admin/textbooks" /> + } href="/admin/questions" /> + } href="/admin/exams" /> ) @@ -97,9 +97,9 @@ export function AdminHomeworkActivityCard({ t, streams }: { t: TranslationFuncti {t("sections.homeworkActivity")} - } /> - } /> - } /> + } href="/admin/homework/assignments" /> + } href="/admin/homework/submissions" /> + } href="/admin/homework/submissions?status=submitted" /> ) @@ -134,6 +134,8 @@ export function AdminUserRolesCard({ t, streams }: { t: TranslationFunction; str // ─── 趋势图表 ────────────────────────────────────────────── export function AdminTrendCharts({ t }: { t: TranslationFunction }) { + // TODO(V4-P3-2): 趋势数据待接入真实统计查询(见 data-access.ts P2-4 TODO) + // 当前 data-access.getAdminDashboardData 返回空数组,此处渲染空状态 return (
@@ -214,12 +216,14 @@ function ContentRow({ label, value, icon, + href, }: { label: string value: number icon: React.ReactNode + href?: string }) { - return ( + const content = (
{icon} @@ -228,4 +232,14 @@ function ContentRow({
{value}
) + + if (href) { + return ( + + {content} + + ) + } + + return content } diff --git a/src/modules/dashboard/components/comparison-badge.tsx b/src/modules/dashboard/components/comparison-badge.tsx new file mode 100644 index 0000000..0fc6c06 --- /dev/null +++ b/src/modules/dashboard/components/comparison-badge.tsx @@ -0,0 +1,82 @@ +import { ArrowDown, ArrowUp, Minus } from "lucide-react" +import { cn } from "@/shared/lib/utils" + +/** 对比变化方向 */ +export type ComparisonTrend = "up" | "down" | "flat" + +/** 对比计算结果 */ +export interface ComparisonResult { + /** 当前值 */ + current: number + /** 上一周期值 */ + previous: number + /** 变化百分比(-100 到 +∞) */ + changePercent: number + /** 趋势方向 */ + trend: ComparisonTrend +} + +/** + * 计算两个周期的变化百分比(L3 纯函数,便于单测)。 + * + * - 上一周期为 0 且当前 > 0:返回 +100(新增) + * - 上一周期为 0 且当前为 0:返回 0(持平) + * - 正常情况:(current - previous) / previous * 100 + */ +export function computeComparison(current: number, previous: number): ComparisonResult { + if (previous === 0) { + return { + current, + previous, + changePercent: current > 0 ? 100 : 0, + trend: current > 0 ? "up" : "flat", + } + } + + const changePercent = ((current - previous) / Math.abs(previous)) * 100 + const trend: ComparisonTrend = + Math.abs(changePercent) < 0.01 ? "flat" : changePercent > 0 ? "up" : "down" + + return { current, previous, changePercent, trend } +} + +/** + * 数据对比徽章组件(L3)。 + * + * 显示当前值与上一周期的变化百分比,带方向图标和颜色。 + * 用于仪表盘统计卡片旁的对比指示。 + */ +export function ComparisonBadge({ + result, + className, +}: { + result: ComparisonResult + className?: string +}) { + const { changePercent, trend } = result + const absPercent = Math.abs(Math.round(changePercent)) + + const trendConfig = { + up: { icon: ArrowUp, color: "text-emerald-600 bg-emerald-50 dark:bg-emerald-950/30" }, + down: { icon: ArrowDown, color: "text-red-600 bg-red-50 dark:bg-red-950/30" }, + flat: { icon: Minus, color: "text-muted-foreground bg-muted/50" }, + } as const + + const config = trendConfig[trend] + const Icon = config.icon + + return ( + + + {absPercent}% + + ) +} diff --git a/src/modules/dashboard/components/dashboard-greeting-header.tsx b/src/modules/dashboard/components/dashboard-greeting-header.tsx index 4bba9b2..d9e5882 100644 --- a/src/modules/dashboard/components/dashboard-greeting-header.tsx +++ b/src/modules/dashboard/components/dashboard-greeting-header.tsx @@ -25,7 +25,7 @@ export async function DashboardGreetingHeader({

- {t(`greeting.${greetingKey}`)},{userName} + {userName ? `${t(`greeting.${greetingKey}`)},${userName}` : t(`greeting.${greetingKey}`)}

{t("greeting.todayIs", { date: today })}

diff --git a/src/modules/dashboard/components/dashboard-notification-widget.tsx b/src/modules/dashboard/components/dashboard-notification-widget.tsx new file mode 100644 index 0000000..2838b6a --- /dev/null +++ b/src/modules/dashboard/components/dashboard-notification-widget.tsx @@ -0,0 +1,94 @@ +"use client" + +import Link from "next/link" +import { Bell, ChevronRight } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * 仪表盘通知中心 Widget(L5)。 + * + * 集成到仪表盘侧边栏,显示最近通知摘要。 + * 完整通知下拉由 SiteHeader 的 NotificationDropdown 处理, + * 此 Widget 提供仪表盘内的快速入口。 + */ +export interface DashboardNotificationItem { + id: string + title: string + body: string + createdAt: string + read: boolean + href?: string +} + +export function DashboardNotificationWidget({ + notifications, + viewAllHref = "/notifications", +}: { + notifications: readonly DashboardNotificationItem[] + viewAllHref?: string +}) { + const t = useTranslations("dashboard") + const unreadCount = notifications.filter((n) => !n.read).length + + return ( + + + + + {t("sections.notifications")} + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + + {notifications.length === 0 ? ( + + ) : ( +
    + {notifications.slice(0, 5).map((n) => ( +
  • + +
    +
    + {!n.read && ( + + )} + + {n.title} + +
    +

    {n.body}

    +
    + + +
  • + ))} +
+ )} +
+ +
+
+
+ ) +} diff --git a/src/modules/dashboard/components/dashboard-responsive-layout.tsx b/src/modules/dashboard/components/dashboard-responsive-layout.tsx new file mode 100644 index 0000000..38a2ace --- /dev/null +++ b/src/modules/dashboard/components/dashboard-responsive-layout.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react" +import { cn } from "@/shared/lib/utils" + +/** + * 仪表盘移动端响应式布局(L8)。 + * + * 根据屏幕尺寸自动调整 Widget 布局: + * - 移动端(< sm):单列堆叠,重要 Widget 优先 + * - 平板(sm - lg):双列网格 + * - 桌面(>= lg):按配置的 layoutClassName 渲染 + * + * 通过 CSS Grid 的 order 属性实现移动端优先级排序, + * 避免重复渲染(P2-9 教训)。 + */ +export function DashboardResponsiveLayout({ + children, + className, + mobileFirstSlot, +}: { + children: ReactNode + className?: string + /** 移动端置顶的 Widget(如今日课表/待办) */ + mobileFirstSlot?: ReactNode +}) { + return ( +
+ {mobileFirstSlot && ( +
{mobileFirstSlot}
+ )} + {children} +
+ ) +} + +/** + * 移动端水平滑动卡片容器(L8)。 + * + * - snap-x snap-mandatory 提供卡片吸附效果 + * - 隐藏滚动条但保留滚动功能 + * - 仅在移动端生效,桌面端转为网格 + */ +export function MobileSwipeContainer({ + children, + className, + ariaLabel, +}: { + children: ReactNode + className?: string + ariaLabel?: string +}) { + return ( +
+ {children} +
+ ) +} + +/** + * 桌面端网格容器(L8)。 + * + * 与 MobileSwipeContainer 配对使用, + * 桌面端显示网格,移动端隐藏(由 MobileSwipeContainer 接管)。 + */ +export function DesktopGrid({ + children, + className, + columns = 3, +}: { + children: ReactNode + className?: string + columns?: 2 | 3 | 4 +}) { + const gridCols = { + 2: "sm:grid-cols-2", + 3: "sm:grid-cols-2 lg:grid-cols-3", + 4: "sm:grid-cols-2 lg:grid-cols-4", + }[columns] + + return ( +
+ {children} +
+ ) +} diff --git a/src/modules/dashboard/components/dashboard-section.tsx b/src/modules/dashboard/components/dashboard-section.tsx index 13b32f9..eccfc3c 100644 --- a/src/modules/dashboard/components/dashboard-section.tsx +++ b/src/modules/dashboard/components/dashboard-section.tsx @@ -1,57 +1,13 @@ "use client" -import { Component, type ReactNode, Suspense } from "react" +import { type ReactNode, Suspense } from "react" import { AlertCircle } from "lucide-react" import { EmptyState } from "@/shared/components/ui/empty-state" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" import { useTranslations } from "next-intl" - -/** - * 仪表盘分区 Error Boundary - * - * 包裹每个独立数据区块,避免单个区块崩溃导致整页不可用。 - * 与路由级 error.tsx 不同,此组件仅替换出错区块,其余区块继续渲染。 - */ -export class DashboardSectionErrorBoundary extends Component< - { children: ReactNode }, - { hasError: boolean } -> { - state: { hasError: boolean } = { hasError: false } - - static getDerivedStateFromError(): { hasError: boolean } { - return { hasError: true } - } - - handleRetry = (): void => { - this.setState({ hasError: false }) - } - - render(): ReactNode { - if (this.state.hasError) { - return - } - return this.props.children - } -} - -function DashboardSectionErrorFallback({ - onRetry, -}: { - onRetry: () => void -}): ReactNode { - const t = useTranslations("dashboard.error") - return ( - - ) -} +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" /** * 分区骨架屏变体 @@ -148,23 +104,53 @@ export function DashboardSectionSkeleton({ * 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。 * 单个区块出错或加载中时,仅影响该区块,不波及整页。 * + * 使用共享 SectionErrorBoundary 替代模块特定的 DashboardSectionErrorBoundary 类。 + * + * V4(P3-1)新增 `ariaLabel` prop:传入时渲染 `
`, + * 使键盘用户可按逻辑顺序遍历各 Widget,提升 a11y。 + * * @example - * + * * * */ export function DashboardSection({ children, variant = "card", + ariaLabel, }: { children: ReactNode variant?: SkeletonVariant + /** 传入时渲染为可聚焦的 region,提升键盘导航 a11y */ + ariaLabel?: string }): ReactNode { - return ( - + const t = useTranslations("dashboard.error") + + const fallback = (): ReactNode => ( + window.location.reload() }} + className="h-auto border-none shadow-none" + /> + ) + + const content = ( + }> {children} - + ) + + if (ariaLabel) { + return ( +
+ {content} +
+ ) + } + + return content } diff --git a/src/modules/dashboard/components/dashboard-time-range-filter.tsx b/src/modules/dashboard/components/dashboard-time-range-filter.tsx new file mode 100644 index 0000000..de91bf2 --- /dev/null +++ b/src/modules/dashboard/components/dashboard-time-range-filter.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useRouter, useSearchParams, usePathname } from "next/navigation" +import { useCallback } from "react" +import { CalendarDays, CalendarRange, CalendarClock } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { cn } from "@/shared/lib/utils" +import { useTranslations } from "next-intl" + +/** 时间范围选项 */ +export type TimeRange = "today" | "week" | "month" + +const RANGE_OPTIONS: { value: TimeRange; icon: typeof CalendarDays }[] = [ + { value: "today", icon: CalendarDays }, + { value: "week", icon: CalendarRange }, + { value: "month", icon: CalendarClock }, +] + +/** + * 仪表盘时间范围筛选器(L2)。 + * + * 通过 URL search param `?range=` 持久化选择, + * 切换时触发页面重新获取数据。 + * 默认值为 "today"。 + */ +export function DashboardTimeRangeFilter({ className }: { className?: string }) { + const t = useTranslations("dashboard.timeRange") + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const currentRange = (searchParams.get("range") as TimeRange | null) ?? "today" + + const handleRangeChange = useCallback( + (range: TimeRange) => { + const params = new URLSearchParams(searchParams.toString()) + params.set("range", range) + router.push(`${pathname}?${params.toString()}`, { scroll: false }) + }, + [router, pathname, searchParams], + ) + + return ( +
+ {RANGE_OPTIONS.map(({ value, icon: Icon }) => ( + + ))} +
+ ) +} diff --git a/src/modules/dashboard/components/parent-dashboard/parent-dashboard.tsx b/src/modules/dashboard/components/parent-dashboard/parent-dashboard.tsx new file mode 100644 index 0000000..cd78c27 --- /dev/null +++ b/src/modules/dashboard/components/parent-dashboard/parent-dashboard.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from "react" +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 { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils" + +/** + * 家长仪表盘视图组件(V4 P1-4 从 parent 模块迁移至 dashboard 模块)。 + * + * 通过组合(slots)注入家长专属子组件,避免直接 import parent 模块组件, + * 符合"模块内部组件绝不直接 import 其他业务模块"的解耦原则。 + * + * @param parentName 家长名称 + * @param childrenCount 关联子女数 + * @param childrenSlot 子女卡片列表(由调用方注入 ChildCard 组件) + * @param attentionBannerSlot 关注横幅(由调用方注入 ParentAttentionBanner 组件) + * @param aiSummarySlot AI 学情摘要区域(由调用方注入 AiChildSummary 组件,可选) + */ +export async function ParentDashboard({ + parentName, + childrenCount, + childrenSlot, + attentionBannerSlot, + aiSummarySlot, +}: { + parentName: string + childrenCount: number + childrenSlot: ReactNode + attentionBannerSlot: ReactNode + aiSummarySlot?: ReactNode +}) { + const t = await getTranslations("dashboard") + const hasChildren = childrenCount > 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 ( +
+
+

{t("title.parent")}

+
+ {t(`greeting.${greetingKey}`)} + {parentName ? `, ${parentName}` : ""}. {t("description.parent")} +
+
+ + {hasChildren ? ( + <> + {attentionBannerSlot} + + + +
+ + + {t("badge.childrenLinked", { count: childrenCount })} + +
+ + {childrenSlot} + + {aiSummarySlot} + + ) : ( + + )} +
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx index 1eff743..49ed3ee 100644 --- a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx @@ -59,7 +59,7 @@ async function StudentDashboardBody({ - + - + - +
diff --git a/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx b/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx index d0dc521..46650e4 100644 --- a/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx @@ -12,13 +12,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui import { EmptyState } from "@/shared/components/ui/empty-state" import { useCurrentTime } from "@/shared/hooks" import { cn } from "@/shared/lib/utils" +import { timeToMinutes } from "@/modules/dashboard/lib/dashboard-utils" import type { StudentTodayScheduleItem } from "@/modules/dashboard/types" -const timeToMinutes = (t: string): number => { - const [h, m] = t.split(":").map(Number) - return (h ?? 0) * 60 + (m ?? 0) -} - export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) { const t = useTranslations("dashboard") const hasSchedule = items.length > 0 diff --git a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx index 1629ae4..a091346 100644 --- a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx @@ -9,40 +9,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import { StatusBadge } from "@/shared/components/ui/status-badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { formatDate, cn } from "@/shared/lib/utils" +import { getActionLabelKey, getActionVariant, getDueUrgency } from "@/modules/dashboard/lib/dashboard-utils" import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types" import { STUDENT_HOMEWORK_PROGRESS_VARIANT, STUDENT_HOMEWORK_PROGRESS_LABEL, } from "@/modules/homework/types" -const getActionLabelKey = (status: string): "action.review" | "action.view" | "action.continue" | "action.start" => { - if (status === "graded") return "action.review" - if (status === "submitted") return "action.view" - if (status === "in_progress") return "action.continue" - return "action.start" -} - -const getActionVariant = (status: string): "default" | "secondary" | "outline" => { - if (status === "graded" || status === "submitted") return "outline" - return "default" -} - -const getDueUrgency = (dueAt: string | null): "overdue" | "urgent" | "warning" | "normal" | null => { - if (!dueAt) return null - const now = new Date() - const due = new Date(dueAt) - const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60) - - if (diffHours < 0) return "overdue" - if (diffHours < 48) return "urgent" - if (diffHours < 120) return "warning" - return "normal" -} - export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) { const t = await getTranslations("dashboard") const locale = await getLocale() const hasAssignments = upcomingAssignments.length > 0 + const now = new Date() return ( @@ -78,7 +56,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { {upcomingAssignments.map((a) => { - const urgency = getDueUrgency(a.dueAt) + const urgency = getDueUrgency(a.dueAt, now) const isGraded = a.progressStatus === "graded" return ( diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx index 127a005..21c3d8b 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -64,7 +64,7 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData & - + {/* 课表:移动端首位,桌面端右上 — 仅渲染一次(P2-9 修复,原为双实例) */}
- +
- + - + - + diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx index 3327ae3..976ace6 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx @@ -6,35 +6,13 @@ import { CalendarDays, CalendarX, MapPin } from "lucide-react" import { EmptyState } from "@/shared/components/ui/empty-state" import { cn } from "@/shared/lib/utils" import { ScrollArea } from "@/shared/components/ui/scroll-area" - -type TeacherTodayScheduleItem = { - id: string - classId: string - className: string - course: string - startTime: string - endTime: string - location: string | null -} +import { getScheduleStatus } from "@/modules/dashboard/lib/dashboard-utils" +import type { TeacherTodayScheduleItem } from "@/modules/dashboard/types" export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) { const t = await getTranslations("dashboard") const hasSchedule = items.length > 0 - const getStatus = (start: string, end: string): "live" | "upcoming" | "past" => { - const now = new Date() - const currentTime = now.getHours() * 60 + now.getMinutes() - - const [startH, startM] = start.split(":").map(Number) - const [endH, endM] = end.split(":").map(Number) - const startTime = (Number.isFinite(startH) ? startH : 0) * 60 + (Number.isFinite(startM) ? startM : 0) - const endTime = (Number.isFinite(endH) ? endH : 0) * 60 + (Number.isFinite(endM) ? endM : 0) - - if (currentTime >= startTime && currentTime <= endTime) return "live" - if (currentTime < startTime) return "upcoming" - return "past" - } - return ( @@ -60,7 +38,7 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt
{items.map((item, index) => { - const status = getStatus(item.startTime, item.endTime) + const status = getScheduleStatus(item.startTime, item.endTime, new Date()) const isLive = status === "live" const isPast = status === "past" const isLast = index === items.length - 1 diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx index f644d0f..062bf59 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx @@ -27,6 +27,7 @@ export async function TeacherStats({ href="/teacher/homework/submissions?status=submitted" highlight={toGradeCount > 0} color="text-amber-500" + valueClassName="tabular-nums" />
) diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx index 8b16c0a..1e8504c 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx @@ -21,6 +21,13 @@ const VARIANT_STYLES: Record = { + urgent: 0, + normal: 1, + info: 2, +} + export async function TeacherTodoCard({ items }: TeacherTodoCardProps) { const t = await getTranslations("dashboard") const hasItems = items.some((item) => item.count > 0) @@ -49,11 +56,7 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
{items .filter((item) => item.count > 0) - .sort((a, b) => { - if (a.variant === "urgent" && b.variant !== "urgent") return -1 - if (a.variant !== "urgent" && b.variant === "urgent") return 1 - return 0 - }) + .sort((a, b) => VARIANT_PRIORITY[a.variant] - VARIANT_PRIORITY[b.variant]) .map((item, idx) => { const style = VARIANT_STYLES[item.variant] const Icon = style.icon diff --git a/src/modules/dashboard/config/widget-configs.ts b/src/modules/dashboard/config/widget-configs.ts new file mode 100644 index 0000000..0ca38a8 --- /dev/null +++ b/src/modules/dashboard/config/widget-configs.ts @@ -0,0 +1,67 @@ +/** + * 各角色仪表盘 Widget 布局配置。 + * + * 通过配置驱动决定每个角色渲染哪些 Widget 及其布局位置。 + * 新增角色或调整 Widget 只需修改此配置文件,不需动组件代码。 + */ + +import type { DashboardLayoutConfig } from "@/modules/dashboard/types" + +/** 管理员仪表盘 Widget 配置 */ +export const ADMIN_WIDGET_CONFIG: DashboardLayoutConfig = { + role: "admin", + widgets: [ + { id: "admin-stats-bar", slot: "stats", skeletonVariant: "stats", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", defaultVisible: true }, + { id: "admin-quick-actions", slot: "actions", skeletonVariant: "card", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-3", defaultVisible: true }, + { id: "admin-trend-charts", slot: "charts", skeletonVariant: "chart", layoutClassName: "grid gap-6 lg:grid-cols-2", defaultVisible: true }, + { id: "admin-user-roles", slot: "cards-left", skeletonVariant: "card", layoutClassName: "lg:col-span-1", defaultVisible: true }, + { id: "admin-content", slot: "cards-center", skeletonVariant: "card", layoutClassName: "lg:col-span-1", defaultVisible: true }, + { id: "admin-homework-activity", slot: "cards-right", skeletonVariant: "card", layoutClassName: "lg:col-span-1", defaultVisible: true }, + { id: "admin-recent-users", slot: "table", skeletonVariant: "table", layoutClassName: "", defaultVisible: true }, + ], +} + +/** 教师仪表盘 Widget 配置 */ +export const TEACHER_WIDGET_CONFIG: DashboardLayoutConfig = { + role: "teacher", + widgets: [ + { id: "teacher-stats", slot: "stats", skeletonVariant: "stats", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", defaultVisible: true }, + { id: "teacher-todo", slot: "main-top", skeletonVariant: "card", layoutClassName: "lg:col-span-8", defaultVisible: true }, + { id: "teacher-schedule", slot: "sidebar-top", skeletonVariant: "card", layoutClassName: "lg:col-span-4", defaultVisible: true }, + { id: "teacher-grade-trends", slot: "main-middle", skeletonVariant: "chart", layoutClassName: "lg:col-span-8", defaultVisible: true }, + { id: "teacher-recent-submissions", slot: "main-bottom", skeletonVariant: "list", layoutClassName: "lg:col-span-8", defaultVisible: true }, + { id: "teacher-homework", slot: "sidebar-middle", skeletonVariant: "list", layoutClassName: "lg:col-span-4", defaultVisible: true }, + { id: "teacher-classes", slot: "sidebar-bottom", skeletonVariant: "list", layoutClassName: "lg:col-span-4", defaultVisible: true }, + ], +} + +/** 学生仪表盘 Widget 配置 */ +export const STUDENT_WIDGET_CONFIG: DashboardLayoutConfig = { + role: "student", + widgets: [ + { id: "student-stats", slot: "stats", skeletonVariant: "stats", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", defaultVisible: true }, + { id: "student-upcoming-assignments", slot: "main-top", skeletonVariant: "list", layoutClassName: "lg:col-span-2", defaultVisible: true }, + { id: "student-grades", slot: "main-bottom", skeletonVariant: "card", layoutClassName: "lg:col-span-2", defaultVisible: true }, + { id: "student-today-schedule", slot: "sidebar", skeletonVariant: "card", layoutClassName: "", defaultVisible: true }, + ], +} + +/** 家长仪表盘 Widget 配置 */ +export const PARENT_WIDGET_CONFIG: DashboardLayoutConfig = { + role: "parent", + widgets: [ + { id: "parent-attention-banner", slot: "banner", skeletonVariant: "card", layoutClassName: "", defaultVisible: true }, + { id: "parent-quick-entries", slot: "actions", skeletonVariant: "card", layoutClassName: "grid grid-cols-2 gap-3 sm:grid-cols-4", defaultVisible: true }, + { id: "parent-children-cards", slot: "main", skeletonVariant: "list", layoutClassName: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", defaultVisible: true }, + ], +} + +/** 按角色获取 Widget 配置 */ +export function getWidgetConfig(role: DashboardLayoutConfig["role"]): DashboardLayoutConfig { + switch (role) { + case "admin": return ADMIN_WIDGET_CONFIG + case "teacher": return TEACHER_WIDGET_CONFIG + case "student": return STUDENT_WIDGET_CONFIG + case "parent": return PARENT_WIDGET_CONFIG + } +} diff --git a/src/modules/dashboard/data-access.ts b/src/modules/dashboard/data-access.ts index fd2a42d..91dff33 100644 --- a/src/modules/dashboard/data-access.ts +++ b/src/modules/dashboard/data-access.ts @@ -43,6 +43,8 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise + +/** + * 从 localStorage 读取偏好(同步,用于 lazy initializer)。 + */ +function loadPreferences(role: DashboardRole): WidgetPreferences { + if (typeof window === "undefined") return {} + try { + const stored = localStorage.getItem(`${STORAGE_KEY}-${role}`) + return stored ? (JSON.parse(stored) as WidgetPreferences) : {} + } catch { + return {} + } +} + +/** + * 仪表盘自定义偏好 Hook(L4)。 + * + * 从 localStorage 读取用户对 Widget 显示/隐藏的偏好, + * 与默认配置合并,支持运行时切换。 + * + * - 首次访问返回默认配置的 `defaultVisible` + * - 用户切换后持久化到 localStorage + * - 支持重置为默认 + */ +export function useDashboardPreferences(role: DashboardRole) { + // 使用 lazy initializer 避免 useEffect 中调用 setState + const [preferences, setPreferences] = useState(() => loadPreferences(role)) + + const config = getWidgetConfig(role) + + /** 合并默认配置与用户偏好 */ + const effectiveWidgets: DashboardWidgetConfig[] = config.widgets.map((w) => ({ + ...w, + defaultVisible: preferences[w.id] ?? w.defaultVisible, + })) + + /** 切换某个 Widget 的可见性 */ + const toggleWidget = useCallback( + (widgetId: string) => { + setPreferences((prev) => { + const widget = config.widgets.find((w) => w.id === widgetId) + if (!widget) return prev + const currentVisible = prev[widgetId] ?? widget.defaultVisible + const next = { ...prev, [widgetId]: !currentVisible } + try { + localStorage.setItem(`${STORAGE_KEY}-${role}`, JSON.stringify(next)) + } catch { + // localStorage 不可用,仅更新内存状态 + } + return next + }) + }, + [config.widgets, role], + ) + + /** 重置为默认配置 */ + const resetToDefault = useCallback(() => { + setPreferences({}) + try { + localStorage.removeItem(`${STORAGE_KEY}-${role}`) + } catch { + // localStorage 不可用 + } + }, [role]) + + return { + widgets: effectiveWidgets, + toggleWidget, + resetToDefault, + } +} diff --git a/src/modules/dashboard/hooks/use-dashboard-realtime.ts b/src/modules/dashboard/hooks/use-dashboard-realtime.ts new file mode 100644 index 0000000..093d69a --- /dev/null +++ b/src/modules/dashboard/hooks/use-dashboard-realtime.ts @@ -0,0 +1,80 @@ +"use client" + +import { useEffect, useState } from "react" + +/** + * 仪表盘实时更新 Hook(L6)。 + * + * 基于 Server-Sent Events (SSE) 实现轻量级实时更新, + * 避免引入 WebSocket 的复杂依赖。 + * + * - 自动重连(指数退避,最大 30s) + * - 组件卸载时自动清理 EventSource + * - 连接状态可查询(connecting/connected/error) + * + * @param url SSE 端点 URL + * @param eventName 监听的事件名(默认 "update") + */ +export type ConnectionStatus = "connecting" | "connected" | "error" | "closed" + +export function useDashboardRealtime( + url: string | null, + eventName = "update", +): { + status: ConnectionStatus + lastMessage: T | null + lastUpdatedAt: Date | null +} { + const [status, setStatus] = useState("connecting") + const [lastMessage, setLastMessage] = useState(null) + const [lastUpdatedAt, setLastUpdatedAt] = useState(null) + + useEffect(() => { + if (!url) { + setStatus("closed") + return + } + + let eventSource: EventSource | null = null + let retryCount = 0 + let retryTimer: ReturnType | null = null + + const connect = () => { + setStatus("connecting") + eventSource = new EventSource(url) + + eventSource.onopen = () => { + retryCount = 0 + setStatus("connected") + } + + eventSource.addEventListener(eventName, (event) => { + try { + const data = JSON.parse(event.data) as T + setLastMessage(data) + setLastUpdatedAt(new Date()) + } catch { + // 忽略解析失败的消息 + } + }) + + eventSource.onerror = () => { + setStatus("error") + eventSource?.close() + // 指数退避重连,最大 30s + const delay = Math.min(1000 * Math.pow(2, retryCount), 30000) + retryCount++ + retryTimer = setTimeout(connect, delay) + } + } + + connect() + + return () => { + eventSource?.close() + if (retryTimer) clearTimeout(retryTimer) + } + }, [url, eventName]) + + return { status, lastMessage, lastUpdatedAt } +} diff --git a/src/modules/dashboard/lib/dashboard-utils.ts b/src/modules/dashboard/lib/dashboard-utils.ts index f0596cd..faf159c 100644 --- a/src/modules/dashboard/lib/dashboard-utils.ts +++ b/src/modules/dashboard/lib/dashboard-utils.ts @@ -98,14 +98,14 @@ export function sortUpcomingAssignments( /** * 从课表中筛选指定周几的课程,按开始时间升序排序。 * - * 泛型 T 允许调用方指定返回的课表项类型(StudentTodayScheduleItem 或 - * TeacherTodayScheduleItem)。两者结构完全相同,泛型仅用于类型层面。 + * `StudentTodayScheduleItem` 与 `TeacherTodayScheduleItem` 结构完全相同, + * 返回 `StudentTodayScheduleItem[]` 可通过结构化类型赋值给任一类型变量。 */ -export function filterTodaySchedule( +export function filterTodaySchedule( schedule: readonly ClassScheduleItem[], weekday: Weekday, classNameById?: ReadonlyMap, -): T[] { +): StudentTodayScheduleItem[] { return schedule .filter((s) => s.weekday === weekday) .sort((a, b) => a.startTime.localeCompare(b.startTime)) @@ -117,7 +117,7 @@ export function filterTodaySchedule [c.id, c.name] as const)) - const todayScheduleItems = filterTodaySchedule( + const todayScheduleItems = filterTodaySchedule( schedule, todayWeekday, classNameById, @@ -196,3 +196,84 @@ export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" { /** 重导出 TeacherDashboardData 便于 actions 使用 */ export type { TeacherDashboardData } + +// ─── 课表状态计算 ────────────────────────────────────────── + +/** + * 将 "HH:MM" 格式的时间字符串转换为当天的分钟数。 + * 无效输入返回 0。 + */ +export function timeToMinutes(t: string): number { + const [h, m] = t.split(":").map(Number) + return (h ?? 0) * 60 + (m ?? 0) +} + +/** 课表项的实时状态 */ +export type ScheduleStatus = "live" | "upcoming" | "past" + +/** + * 根据当前时间判断课程状态:进行中 / 即将开始 / 已结束。 + */ +export function getScheduleStatus( + start: string, + end: string, + now: Date, +): ScheduleStatus { + const currentTime = now.getHours() * 60 + now.getMinutes() + const startTime = timeToMinutes(start) + const endTime = timeToMinutes(end) + + if (currentTime >= startTime && currentTime <= endTime) return "live" + if (currentTime < startTime) return "upcoming" + return "past" +} + +// ─── 作业紧急度计算 ──────────────────────────────────────── + +/** 作业截止时间的紧急度等级 */ +export type DueUrgency = "overdue" | "urgent" | "warning" | "normal" | null + +/** + * 根据截止时间与当前时间的差值计算紧急度。 + * - overdue: 已逾期 + * - urgent: 48 小时内 + * - warning: 120 小时内(5 天) + * - normal: 5 天以上 + * - null: 无截止时间 + */ +export function getDueUrgency(dueAt: string | null, now: Date): DueUrgency { + if (!dueAt) return null + const due = new Date(dueAt) + const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60) + + if (diffHours < 0) return "overdue" + if (diffHours < 48) return "urgent" + if (diffHours < 120) return "warning" + return "normal" +} + +// ─── 学生作业操作按钮 ───────────────────────────────────── + +/** 作业操作按钮的 i18n 键 */ +export type ActionLabelKey = "action.review" | "action.view" | "action.continue" | "action.start" + +/** + * 根据作业进度状态返回操作按钮的 i18n 键。 + */ +export function getActionLabelKey(status: string): ActionLabelKey { + if (status === "graded") return "action.review" + if (status === "submitted") return "action.view" + if (status === "in_progress") return "action.continue" + return "action.start" +} + +/** 作业操作按钮的视觉变体 */ +export type ActionVariant = "default" | "secondary" | "outline" + +/** + * 根据作业进度状态返回操作按钮的视觉变体。 + */ +export function getActionVariant(status: string): ActionVariant { + if (status === "graded" || status === "submitted") return "outline" + return "default" +} diff --git a/src/modules/dashboard/services/dashboard-service.tsx b/src/modules/dashboard/services/dashboard-service.tsx new file mode 100644 index 0000000..eedfb73 --- /dev/null +++ b/src/modules/dashboard/services/dashboard-service.tsx @@ -0,0 +1,118 @@ +"use client" + +import { createContext, useContext, type ReactNode } from "react" + +import type { ActionState } from "@/shared/types/action-state" +import type { + TeacherDashboardData, + StudentDashboardProps, +} from "@/modules/dashboard/types" +import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils" +import type { ParentDashboardData } from "@/modules/parent/types" +import type { UsersDashboardStats } from "@/modules/users/data-access" +import type { ClassesDashboardStats } from "@/modules/classes/data-access" +import type { TextbooksDashboardStats } from "@/modules/textbooks/data-access" +import type { QuestionsDashboardStats } from "@/modules/questions/data-access" +import type { ExamsDashboardStats } from "@/modules/exams/data-access" +import type { HomeworkDashboardStats } from "@/modules/homework/stats-service" + +// ─── 数据服务接口 ────────────────────────────────────────── + +/** + * 仪表盘数据服务接口(抽象数据依赖)。 + * + * 每个角色提供独立的实现,封装对 data-access 的调用并加入权限校验。 + * 组件通过 `useDashboardService()` 获取当前注入的实现,不直接 import actions。 + * 测试时可注入 mock 实现以隔离数据层。 + */ +export interface DashboardService { + /** 获取管理员仪表盘数据(流式:返回未解析 Promise 供各分区独立消费) */ + getAdminStreams(): Promise + /** 获取教师仪表盘数据 */ + getTeacherData(): Promise> + /** 获取学生仪表盘数据 */ + getStudentData(): Promise | null + }>> + /** 获取家长仪表盘数据 */ + getParentData(): Promise> +} + +/** 管理员仪表盘流式数据源(各分区独立 Promise) */ +export interface AdminDashboardStreams { + usersStats: Promise + classesStats: Promise + textbooksStats: Promise + questionsStats: Promise + examsStats: Promise + homeworkStats: Promise +} + +// ─── 监控埋点接口 ────────────────────────────────────────── + +/** + * 仪表盘监控埋点接口。 + * + * 预留关键操作埋点,供后续接入实际监控 SDK(如 PostHog / Mixpanel)。 + * 默认实现为空操作,生产环境通过 Provider 注入实际实现。 + */ +export interface DashboardAnalytics { + /** Widget 被点击 */ + trackWidgetClick(widgetId: string, role: string): void + /** 空状态被触发 */ + trackEmptyState(widgetId: string, role: string): void + /** 错误重试 */ + trackErrorRetry(widgetId: string, role: string): void + /** 页面停留 */ + trackPageView(role: string, durationMs: number): void +} + +/** 空操作实现(默认) */ +const noopAnalytics: DashboardAnalytics = { + trackWidgetClick: () => {}, + trackEmptyState: () => {}, + trackErrorRetry: () => {}, + trackPageView: () => {}, +} + +// ─── React Context 依赖注入 ──────────────────────────────── + +const DashboardServiceContext = createContext(null) +const DashboardAnalyticsContext = createContext(noopAnalytics) + +/** 仪表盘服务 Provider(在页面层注入角色特定的实现) */ +export function DashboardServiceProvider({ + service, + analytics, + children, +}: { + service: DashboardService + analytics?: DashboardAnalytics + children: ReactNode +}): ReactNode { + return ( + + + {children} + + + ) +} + +/** 获取当前注入的仪表盘数据服务 */ +export function useDashboardService(): DashboardService { + const service = useContext(DashboardServiceContext) + if (!service) { + throw new Error("useDashboardService must be used within DashboardServiceProvider") + } + return service +} + +/** 获取当前注入的监控埋点接口 */ +export function useDashboardAnalytics(): DashboardAnalytics { + return useContext(DashboardAnalyticsContext) +} diff --git a/src/modules/dashboard/types.ts b/src/modules/dashboard/types.ts index 9eb6e38..88d61c8 100644 --- a/src/modules/dashboard/types.ts +++ b/src/modules/dashboard/types.ts @@ -72,3 +72,36 @@ export type TeacherDashboardData = { teacherName: string gradeTrends: TeacherGradeTrendItem[] } + +// ─── 配置驱动 Widget 渲染 ───────────────────────────────── + +/** 仪表盘角色 */ +export type DashboardRole = "admin" | "teacher" | "student" | "parent" + +/** Widget 骨架屏变体(与 DashboardSection 对齐) */ +export type WidgetSkeletonVariant = "stats" | "card" | "chart" | "table" | "list" + +/** + * Widget 配置项。 + * + * 通过配置决定每个角色仪表盘渲染哪些 Widget 及其布局, + * 新增角色或调整 Widget 只需修改配置,不需动组件代码。 + */ +export interface DashboardWidgetConfig { + /** Widget 唯一标识(用于埋点) */ + id: string + /** Widget 渲染区域标识(用于布局分配) */ + slot: string + /** 骨架屏变体 */ + skeletonVariant: WidgetSkeletonVariant + /** 响应式布局类名(Tailwind grid classes) */ + layoutClassName: string + /** 是否默认显示(可被用户偏好覆盖) */ + defaultVisible: boolean +} + +/** 角色仪表盘布局配置 */ +export interface DashboardLayoutConfig { + role: DashboardRole + widgets: DashboardWidgetConfig[] +} diff --git a/src/modules/diagnostic/actions.ts b/src/modules/diagnostic/actions.ts index aab2454..225ef90 100644 --- a/src/modules/diagnostic/actions.ts +++ b/src/modules/diagnostic/actions.ts @@ -12,6 +12,7 @@ import { getParentIdsByStudentIds } from "@/modules/parent/data-access" import { generateDiagnosticReport, generateClassDiagnosticReport, + generateGradeDiagnosticReport, publishDiagnosticReport, deleteDiagnosticReport, getDiagnosticReportById, @@ -24,6 +25,7 @@ import { import { GenerateStudentReportSchema, GenerateClassReportSchema, + GenerateGradeReportSchema, PublishReportSchema, DeleteReportSchema, } from "./schema" @@ -80,6 +82,32 @@ export async function generateClassReportAction( } } +/** v4-P2-3: 生成年级诊断报告 */ +export async function generateGradeReportAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE) + + const parsed = GenerateGradeReportSchema.safeParse({ + gradeId: formData.get("gradeId"), + period: formData.get("period"), + }) + if (!parsed.success) { + return { success: false, message: "Missing gradeId or period" } + } + + const { gradeId, period } = parsed.data + const id = await generateGradeDiagnosticReport(gradeId, period, ctx.userId) + revalidatePath("/teacher/diagnostic") + revalidatePath("/admin/diagnostic") + return { success: true, message: "Grade diagnostic report generated", data: id } + } catch (e) { + return handleActionError(e) + } +} + /** 发布诊断报告 */ export async function publishReportAction( prevState: ActionState | null, @@ -126,7 +154,7 @@ export async function publishReportAction( try { await createNotification({ userId: studentId, - type: "grade", + type: "diagnostic", title, content, link, @@ -144,7 +172,7 @@ export async function publishReportAction( try { await createNotification({ userId: parentId, - type: "grade", + type: "diagnostic", title, content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。", link: "/parent/diagnostic", @@ -208,7 +236,7 @@ export async function exportDiagnosticReportAction( } const buffer = await exportDiagnosticReportToExcel({ reportId }) - const filename = buildDiagnosticReportFilename(report.period) + const filename = await buildDiagnosticReportFilename(report.period) return { success: true, diff --git a/src/modules/diagnostic/components/class-diagnostic-view.tsx b/src/modules/diagnostic/components/class-diagnostic-view.tsx index 514abe8..9fd1d9c 100644 --- a/src/modules/diagnostic/components/class-diagnostic-view.tsx +++ b/src/modules/diagnostic/components/class-diagnostic-view.tsx @@ -30,7 +30,8 @@ import { } from "@/shared/components/ui/table" import { usePermission } from "@/shared/hooks" import { Permissions } from "@/shared/types/permissions" -import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions" +import { WidgetBoundary } from "@/shared/components/widget-boundary" +import { useDiagnosticService } from "../services/diagnostic-service-context" import type { ClassMasterySummary } from "../types" interface ClassDiagnosticViewProps { @@ -60,6 +61,8 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { const router = useRouter() const { hasPermission } = usePermission() const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) + // v2-P1-4: 通过 Context 注入服务,不直接 import actions + const service = useDiagnosticService() const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) const [isGenerating, setIsGenerating] = useState(false) @@ -71,10 +74,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { const handleGenerate = async () => { if (!summary) return setIsGenerating(true) - const formData = new FormData() - formData.set("classId", summary.classId) - formData.set("period", period) - const result = await generateClassReportAction(null, formData) + const result = await service.generateClassReport(summary.classId, period) setIsGenerating(false) if (result.success) { toast.success(result.message) @@ -86,7 +86,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { /** * v3-P2-5: 按知识点筛选学生。 - * 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。 + * 选择知识点后调用服务获取该知识点上所有学生的掌握度。 */ const handleKpFilter = async (kpId: string) => { setSelectedKpId(kpId) @@ -96,10 +96,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { } setIsFiltering(true) try { - const result = await getClassStudentsByKnowledgePointAction({ - classId: summary.classId, - knowledgePointId: kpId, - }) + const result = await service.getClassStudentsByKp( + summary.classId, + kpId, + ) if (result.success && result.data) { setFilteredStudents(result.data) } else { @@ -127,43 +127,46 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { return (
- {/* 概览 */} -
- - - {t("summary.class")} - - -

{summary.className}

-
-
- - - {t("summary.students")} - - -

{summary.studentCount}

-
-
- - - {t("summary.avgMastery")} - - -

{summary.averageMastery.toFixed(1)}%

-
-
- - - {t("summary.needAttention")} - - -

{summary.studentsNeedingAttention.length}

-
-
-
+ {/* v2-P1-6: 概览区块独立 Error Boundary */} + +
+ + + {t("summary.class")} + + +

{summary.className}

+
+
+ + + {t("summary.students")} + + +

{summary.studentCount}

+
+
+ + + {t("summary.avgMastery")} + + +

{summary.averageMastery.toFixed(1)}%

+
+
+ + + {t("summary.needAttention")} + + +

{summary.studentsNeedingAttention.length}

+
+
+
+
- {/* 知识点掌握度热力图 */} + {/* v2-P1-6: 知识点掌握度热力图区块独立 Error Boundary */} + @@ -181,7 +184,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { <>
{summary.knowledgePointStats.map((kp) => { @@ -189,9 +192,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { return (
@@ -230,8 +240,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { )} + - {/* v3-P2-5: 按知识点筛选学生 */} + {/* v2-P1-6: 按知识点筛选学生区块独立 Error Boundary */} + @@ -316,8 +328,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { ) : null} + - {/* 知识点排名表 */} + {/* v2-P1-6: 知识点排名表区块独立 Error Boundary */} + {t("chart.rankingTitle")} @@ -360,8 +374,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { )} + - {/* 需重点关注的学生 */} + {/* v2-P1-6: 需重点关注的学生区块独立 Error Boundary */} + @@ -411,9 +427,11 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { )} + - {/* 生成班级报告 */} + {/* v2-P1-6: 生成班级报告区块独立 Error Boundary */} {canManage ? ( + @@ -442,6 +460,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
+ ) : null}
) diff --git a/src/modules/diagnostic/components/confidence-utils.ts b/src/modules/diagnostic/components/confidence-utils.ts index 07de924..f694b65 100644 --- a/src/modules/diagnostic/components/confidence-utils.ts +++ b/src/modules/diagnostic/components/confidence-utils.ts @@ -1,5 +1,6 @@ /** * v4-P3-7: 诊断报告数据置信度工具。 + * v2-P1-5: 改进为基于知识点数量的多级置信度计算。 * * 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。 * 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。 @@ -9,14 +10,39 @@ import type { DiagnosticReportWithDetails } from "../types" export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient" +/** 置信度阈值(基于知识点数量 = strengths.length + weaknesses.length) */ +const CONFIDENCE_INSUFFICIENT_MAX = 0 +const CONFIDENCE_LOW_MAX = 3 +const CONFIDENCE_MEDIUM_MAX = 8 + /** - * 根据报告数据计算置信度。 - * 简化方案:overallScore === null 表示无数据(insufficient), - * 否则视为高置信度(high)。 - * 后续可扩展为基于 totalQuestions 等数据量字段的多级判断。 + * v2-P1-5: 根据报告数据计算置信度。 + * + * 置信度基于报告中涉及的知识点数量(strengths + weaknesses 数组长度之和): + * - 0 个知识点 → insufficient(数据不足) + * - 1-3 个 → low(数据较少,结论仅供参考) + * - 4-8 个 → medium(数据量一般,建议结合其他信息参考) + * - >8 个 → high(数据充足,报告结论可靠) + * + * 若 overallScore 为 null 也视为 insufficient。 + * + * @param report 诊断报告(含详情) + * @param totalKnowledgePoints 可选:显式传入知识点总数(优先于数组长度推断) */ -export function getConfidenceLevel(report: DiagnosticReportWithDetails): ConfidenceLevel { +export function getConfidenceLevel( + report: DiagnosticReportWithDetails, + totalKnowledgePoints?: number, +): ConfidenceLevel { if (report.overallScore === null) return "insufficient" + + // 优先使用显式传入的知识点数;否则从 strengths + weaknesses 数组推断 + const kpCount = + totalKnowledgePoints ?? + (report.strengths?.length ?? 0) + (report.weaknesses?.length ?? 0) + + if (kpCount <= CONFIDENCE_INSUFFICIENT_MAX) return "insufficient" + if (kpCount <= CONFIDENCE_LOW_MAX) return "low" + if (kpCount <= CONFIDENCE_MEDIUM_MAX) return "medium" return "high" } diff --git a/src/modules/diagnostic/components/mastery-radar-chart.tsx b/src/modules/diagnostic/components/mastery-radar-chart.tsx index 43f4829..9bd3b6e 100644 --- a/src/modules/diagnostic/components/mastery-radar-chart.tsx +++ b/src/modules/diagnostic/components/mastery-radar-chart.tsx @@ -15,15 +15,17 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) { const t = useTranslations("diagnostic") const isEmpty = !data || data.length === 0 + // v2-P2-5: 保留完整 knowledgePoint 作为 angleKey,使 Tooltip 显示完整名称; + // 通过 angleTickFormatter 截断轴上显示文本,避免长名称溢出图表区域。 + const MAX_AXIS_LABEL_LENGTH = 8 + const truncateAxisLabel = (value: string): string => + value.length > MAX_AXIS_LABEL_LENGTH + ? `${value.slice(0, MAX_AXIS_LABEL_LENGTH)}...` + : value + const chartData = isEmpty ? [] - : data.map((d) => ({ - ...d, - shortName: - d.knowledgePoint.length > 8 - ? `${d.knowledgePoint.slice(0, 8)}...` - : d.knowledgePoint, - })) + : data.map((d) => ({ ...d })) const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined) @@ -52,7 +54,8 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) { > (null) const [publishId, setPublishId] = useState(null) - const [shareId, setShareId] = useState(null) const [isBusy, setIsBusy] = useState(false) const updateParam = useCallback( @@ -85,9 +85,7 @@ export function ReportList({ reports }: ReportListProps) { const handlePublish = async () => { if (!publishId) return setIsBusy(true) - const formData = new FormData() - formData.set("id", publishId) - const result = await publishReportAction(null, formData) + const result = await service.publishReport(publishId) setIsBusy(false) if (result.success) { toast.success(result.message) @@ -101,9 +99,7 @@ export function ReportList({ reports }: ReportListProps) { const handleDelete = async () => { if (!deleteId) return setIsBusy(true) - const formData = new FormData() - formData.set("id", deleteId) - const result = await deleteReportAction(null, formData) + const result = await service.deleteReport(deleteId) setIsBusy(false) if (result.success) { toast.success(result.message) @@ -121,7 +117,7 @@ export function ReportList({ reports }: ReportListProps) { const handleExport = async (reportId: string) => { setIsBusy(true) try { - const result = await exportDiagnosticReportAction(reportId) + const result = await service.exportReport(reportId) if (!result.success || !result.data) { toast.error(result.message || t("error.exportFailed")) return @@ -151,18 +147,6 @@ export function ReportList({ reports }: ReportListProps) { } } - // v3-P3-8: 复制报告分享链接到剪贴板 - const handleCopyLink = async (): Promise => { - if (!shareId) return - const url = `${window.location.origin}/teacher/diagnostic/reports/${shareId}` - try { - await navigator.clipboard.writeText(url) - toast.success(t("reportList.copyLinkSuccess")) - } catch { - toast.error(t("reportList.copyLinkFailed")) - } - } - // v4-P3-7: 置信度标签与提示 const confidenceLabel = (level: ConfidenceLevel): string => { if (level === "high") return t("reportList.confidenceHigh") @@ -202,12 +186,6 @@ export function ReportList({ reports }: ReportListProps) { return "-" } - // v3-P3-8: 当前分享的报告及链接 - const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null - const shareUrl = typeof window !== "undefined" && sharedReport - ? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}` - : "" - return (
{/* 过滤器 */} @@ -314,20 +292,6 @@ export function ReportList({ reports }: ReportListProps) { > - {/* v3-P3-8: 分享按钮(仅教师可见) */} - {canManage ? ( - - ) : null} {canManage && r.status === "draft" ? ( -
-
-
- - - - -
) } diff --git a/src/modules/diagnostic/components/student-diagnostic-view.tsx b/src/modules/diagnostic/components/student-diagnostic-view.tsx index 733e824..578544f 100644 --- a/src/modules/diagnostic/components/student-diagnostic-view.tsx +++ b/src/modules/diagnostic/components/student-diagnostic-view.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip" import { EmptyState } from "@/shared/components/ui/empty-state" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import { formatDate } from "@/shared/lib/utils" import { MasteryRadarChart } from "./mastery-radar-chart" import { @@ -16,6 +17,7 @@ import { confidenceBadgeVariant, type ConfidenceLevel, } from "./confidence-utils" +import { getDiagnosticRoleConfig, type DiagnosticRole } from "../role-config" import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" interface StudentDiagnosticViewProps { @@ -23,11 +25,13 @@ interface StudentDiagnosticViewProps { reports: DiagnosticReportWithDetails[] classAverageMastery?: MasteryRadarPoint[] /** - * v3-P2-6: "练习"按钮的跳转基础路径。 - * - 学生视角:默认 `/student/learning/assignments` - * - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选) - * - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口) - * 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。 + * v4-P2-2: 角色配置驱动。 + * 组件内部根据 role 查找 DIAGNOSTIC_ROLE_CONFIG 获取 practiceHrefBase 等角色差异配置。 + * 新增角色只需在 role-config.ts 中添加配置项,无需修改组件 props。 + */ + role?: DiagnosticRole + /** + * @deprecated v4-P2-2: 请改用 `role` prop。保留向后兼容,若同时传入则 role 优先。 */ practiceHrefBase?: string | null } @@ -36,9 +40,12 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery, - practiceHrefBase = "/student/learning/assignments", + role = "student", + practiceHrefBase, }: StudentDiagnosticViewProps) { const t = useTranslations("diagnostic") + // v4-P2-2: 角色配置驱动,role prop 优先于 deprecated practiceHrefBase + const resolvedPracticeHrefBase = practiceHrefBase ?? getDiagnosticRoleConfig(role).practiceHrefBase if (!summary) { return ( @@ -132,11 +139,14 @@ export function StudentDiagnosticView({
- {/* 雷达图 */} - + {/* v2-P1-6: 雷达图区块独立 Error Boundary */} + + + - {/* 强项 / 弱项 */} -
+ {/* v2-P1-6: 强项 / 弱项区块独立 Error Boundary */} + +
@@ -179,9 +189,9 @@ export function StudentDiagnosticView({ {m.knowledgePointName} {m.masteryLevel.toFixed(1)}%
- {practiceHrefBase ? ( + {resolvedPracticeHrefBase ? (
+ - {/* 最新报告 / 建议 */} + {/* v2-P1-6: 最新报告区块独立 Error Boundary */} {latestReport ? ( + @@ -245,10 +257,12 @@ export function StudentDiagnosticView({ ) : null} + ) : null} - {/* 历史报告列表 */} + {/* v2-P1-6: 历史报告区块独立 Error Boundary */} {publishedReports.length > 1 ? ( + @@ -287,6 +301,7 @@ export function StudentDiagnosticView({
+ ) : null} ) diff --git a/src/modules/diagnostic/data-access-reports.ts b/src/modules/diagnostic/data-access-reports.ts index af18f55..cec45df 100644 --- a/src/modules/diagnostic/data-access-reports.ts +++ b/src/modules/diagnostic/data-access-reports.ts @@ -3,17 +3,23 @@ import "server-only" import { createId } from "@paralleldrive/cuid2" import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm" import { cache } from "react" +import { getTranslations } from "next-intl/server" import { db } from "@/shared/db" import { learningDiagnosticReports } from "@/shared/db/schema" -import { getUserNamesByIds } from "@/modules/users/data-access" +import { getUserNamesByIds, getUserIdsByGradeId } from "@/modules/users/data-access" import { getStudentIdsByClassIds } from "@/modules/classes/data-access" import { toNumber } from "@/modules/grades/lib/grade-utils" import { BusinessError } from "@/shared/lib/action-utils" import type { DataScope } from "@/shared/types/permissions" -import { getClassMasterySummary, getStudentMasterySummary } from "./data-access" -import { buildClassReportContent, buildStudentReportContent } from "./stats-service" +import { getClassMasterySummary, getGradeMasterySummary, getStudentMasterySummary } from "./data-access" +import { + buildClassReportContent, + buildGradeReportContent, + buildStudentReportContent, + type ReportContentTranslations, +} from "./stats-service" import type { DiagnosticReport, DiagnosticReportListResult, @@ -21,6 +27,25 @@ import type { DiagnosticReportWithDetails, } from "./types" +/** + * Build report content translations from next-intl. + * Keeps stats-service free of i18n framework dependencies. + */ +async function getReportContentTranslations(): Promise { + const t = await getTranslations("diagnostic.reportContent") + return { + studentSummary: (vars) => t("studentSummary", vars), + studentRecommendation: (vars) => t("studentRecommendation", vars), + studentNoWeakness: t("studentNoWeakness"), + classSummary: (vars) => t("classSummary", vars), + classRecommendation: (vars) => t("classRecommendation", vars), + classNoWeakness: t("classNoWeakness"), + gradeSummary: (vars) => t("gradeSummary", vars), + gradeRecommendation: (vars) => t("gradeRecommendation", vars), + gradeNoWeakness: t("gradeNoWeakness"), + } +} + /** * 诊断报告业务错误(P3-27 修复:结构化错误码,避免直接暴露内部错误)。 * 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。 @@ -31,7 +56,9 @@ export class DiagnosticReportError extends BusinessError { | "STUDENT_NOT_FOUND" | "NO_MASTERY_DATA" | "CLASS_NOT_FOUND" - | "CLASS_NO_MASTERY_DATA", + | "CLASS_NO_MASTERY_DATA" + | "GRADE_NOT_FOUND" + | "GRADE_NO_MASTERY_DATA", message: string, ) { super(message, code) @@ -49,6 +76,7 @@ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): Diag id: r.id, studentId: r.studentId, classId: r.classId, + gradeId: r.gradeId, generatedBy: r.generatedBy, reportType: r.reportType, period: r.period, @@ -76,8 +104,9 @@ export async function generateDiagnosticReport( throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告") } + const translations = await getReportContentTranslations() const { summaryText, strengths, weaknesses, recommendations, overallScore } = - buildStudentReportContent(summary, period) + buildStudentReportContent(summary, period, translations) const id = createId() await db.insert(learningDiagnosticReports).values({ @@ -110,8 +139,9 @@ export async function generateClassDiagnosticReport( throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告") } + const translations = await getReportContentTranslations() const { summaryText, strengths, weaknesses, recommendations, overallScore } = - buildClassReportContent(summary, period) + buildClassReportContent(summary, period, translations) const id = createId() await db.insert(learningDiagnosticReports).values({ @@ -130,6 +160,43 @@ export async function generateClassDiagnosticReport( return id } +/** v4-P2-3: 生成年级诊断报告 */ +export async function generateGradeDiagnosticReport( + gradeId: string, + period: string, + generatedBy: string +): Promise { + const summary = await getGradeMasterySummary(gradeId) + if (!summary) throw new DiagnosticReportError("GRADE_NOT_FOUND", "年级不存在") + + // 当年级存在但无任何掌握度数据时,拒绝生成误导性报告 + if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) { + throw new DiagnosticReportError("GRADE_NO_MASTERY_DATA", "年级暂无掌握度数据,无法生成报告") + } + + const translations = await getReportContentTranslations() + const { summaryText, strengths, weaknesses, recommendations, overallScore } = + buildGradeReportContent(summary, period, translations) + + const id = createId() + await db.insert(learningDiagnosticReports).values({ + id, + studentId: null, + classId: null, + gradeId, + generatedBy, + reportType: "grade", + period, + summary: summaryText, + strengths, + weaknesses, + recommendations, + overallScore: String(overallScore), + status: "draft", + }) + return id +} + /** 查询诊断报告列表(P3-15 修复:支持分页) */ export const getDiagnosticReports = cache( async ( @@ -142,14 +209,14 @@ export const getDiagnosticReports = cache( if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status)) if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period)) - // v4-P1-1: 应用 DataScope 行级权限过滤 + // v4-P1-1 + v2-P1-1: 应用 DataScope 行级权限过滤 // - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null,需通过 classId 关联) // 由于当前 schema 班级报告 studentId=null,无法直接按 classId 过滤,因此对 class_taught scope: // 个人报告按所教班级学生 ID 过滤;班级报告(studentId=null)保留(教师可查看自己生成的班级报告) - // - class_members: 学生角色,调用方已在 filters.studentId 中传入 ctx.userId,无需在此重复过滤 + // - class_members: 学生角色,调用方应在 filters.studentId 中传入 ctx.userId,此处兜底过滤 // - children: 仅返回子女的报告 - // - grade_managed: 返回所辖年级所有学生的报告(通过 studentId IN 所辖年级学生) - // - all: 不过滤 + // - grade_managed: v2-P1-1 修复,返回所辖年级所有学生的报告(通过 getUserIdsByGradeId 查询年级学生 ID) + // - all: 不过滤(admin) if (scope) { if (scope.type === "children") { if (scope.childrenIds.length === 0) { @@ -167,8 +234,21 @@ export const getDiagnosticReports = cache( // 个人报告按学生 ID 过滤;班级报告(studentId=null)由 generatedBy 限制为当前教师 // 这里简化:仅返回所教班级学生的个人报告 conditions.push(inArray(learningDiagnosticReports.studentId, studentIds)) + } else if (scope.type === "grade_managed") { + // v2-P1-1: 年级主任仅返回所辖年级学生的报告 + if (scope.gradeIds.length === 0) { + return { reports: [], total: 0 } + } + const gradeStudentIds = ( + await Promise.all(scope.gradeIds.map((gid) => getUserIdsByGradeId(gid))) + ).flat() + if (gradeStudentIds.length === 0) { + return { reports: [], total: 0 } + } + conditions.push(inArray(learningDiagnosticReports.studentId, gradeStudentIds)) } - // grade_managed 和 all 不在此过滤(grade_managed 需要跨模块查询年级学生,由调用方自行过滤) + // class_members scope: 调用方应在 filters.studentId 中传入 ctx.userId(学生页已正确传入) + // owned 和 all 不在此过滤 } const whereClause = conditions.length > 0 ? and(...conditions) : undefined diff --git a/src/modules/diagnostic/data-access.ts b/src/modules/diagnostic/data-access.ts index 66e8cc8..092650c 100644 --- a/src/modules/diagnostic/data-access.ts +++ b/src/modules/diagnostic/data-access.ts @@ -11,10 +11,12 @@ import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@ import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection" import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" +import { getGradeNameById } from "@/modules/school/data-access" import { aggregateClassMastery, buildClassMasterySummary, + buildGradeMasterySummary, buildStudentMasterySummary, computeKpStats, computeMasteryLevel, @@ -24,6 +26,7 @@ import { } from "./stats-service" import type { ClassMasterySummary, + GradeMasterySummary, KnowledgePointStat, MasteryWithKnowledgePoint, StudentMasterySummary, @@ -360,6 +363,45 @@ export const getClassMasterySummary = cache(async (classId: string): Promise => { + // 年级名称 与 学生列表 相互独立,并行拉取 + const [gradeNameResult, studentIds] = await Promise.all([ + getGradeNameById(gradeId), + getUserIdsByGradeId(gradeId), + ]) + const gradeName = gradeNameResult ?? "Unknown" + + if (studentIds.length === 0) { + return { gradeId, gradeName, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] } + } + + // 学生姓名 与 掌握度记录 相互独立,并行拉取 + const [userMap, masteryRows] = await Promise.all([ + getUserNamesByIds(studentIds), + db + .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name }) + .from(knowledgePointMastery) + .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) + .where(inArray(knowledgePointMastery.studentId, studentIds)), + ]) + + const students = studentIds + .map((id) => ({ id, name: userMap.get(id)?.name ?? null })) + .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) + + const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({ + mastery: { + studentId: r.mastery.studentId, + knowledgePointId: r.mastery.knowledgePointId, + masteryLevel: r.mastery.masteryLevel, + }, + kpName: r.kpName, + })) + + return buildGradeMasterySummary(gradeId, gradeName, students, rawRows) +}) + /** 获取知识点统计(按班级或年级聚合) */ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise => { let studentIds: string[] = [] diff --git a/src/modules/diagnostic/export.ts b/src/modules/diagnostic/export.ts index f4209ff..78ed518 100644 --- a/src/modules/diagnostic/export.ts +++ b/src/modules/diagnostic/export.ts @@ -1,11 +1,24 @@ import "server-only" +import { getTranslations } from "next-intl/server" + import { exportToExcel } from "@/shared/lib/excel" import { formatDateForFile } from "@/shared/lib/utils" +import { BusinessError } from "@/shared/lib/action-utils" import { getDiagnosticReportById } from "./data-access-reports" import { getStudentMasterySummary, getClassMasterySummary } from "./data-access" +/** + * v2-P2-3: 导出报告不存在的结构化错误。 + */ +export class DiagnosticExportError extends BusinessError { + constructor(code: "REPORT_NOT_FOUND", message: string) { + super(message, code) + this.name = "DiagnosticExportError" + } +} + /** * v3-P2-4: 导出诊断报告为 Excel。 * @@ -23,10 +36,13 @@ export async function exportDiagnosticReportToExcel(params: { }): Promise { const report = await getDiagnosticReportById(params.reportId) if (!report) { - throw new Error("Report not found") + // v2-P2-3: 使用结构化错误码,由调用方 i18n 化 + throw new DiagnosticExportError("REPORT_NOT_FOUND", "Report not found") } - const periodLabel = report.period ?? "本期" + const t = await getTranslations("diagnostic") + + const periodLabel = report.period ?? t("parent.selectChild") const overallScore = report.overallScore ?? "-" const strengths = (report.strengths ?? []).join("\n") || "-" const weaknesses = (report.weaknesses ?? []).join("\n") || "-" @@ -37,16 +53,16 @@ export async function exportDiagnosticReportToExcel(params: { // 个人报告 const mastery = await getStudentMasterySummary(report.studentId) const overviewRows = [ - { metric: "学生姓名", value: report.studentName ?? "-" }, - { metric: "报告周期", value: periodLabel }, - { metric: "综合得分", value: overallScore }, - { metric: "报告状态", value: report.status }, - { metric: "生成人", value: report.generatedByName ?? "-" }, - { metric: "生成时间", value: report.createdAt.split("T")[0] }, - { metric: "摘要", value: summary }, - { metric: "强项", value: strengths }, - { metric: "弱项", value: weaknesses }, - { metric: "建议", value: recommendations }, + { metric: t("exportContent.metricStudent"), value: report.studentName ?? "-" }, + { metric: t("exportContent.metricPeriod"), value: periodLabel }, + { metric: t("exportContent.metricScore"), value: overallScore }, + { metric: t("exportContent.metricStatus"), value: report.status }, + { metric: t("exportContent.metricGeneratedBy"), value: report.generatedByName ?? "-" }, + { metric: t("exportContent.metricCreatedAt"), value: report.createdAt.split("T")[0] }, + { metric: t("exportContent.metricSummary"), value: summary }, + { metric: t("exportContent.metricStrengths"), value: strengths }, + { metric: t("exportContent.metricWeaknesses"), value: weaknesses }, + { metric: t("exportContent.metricRecommendations"), value: recommendations }, ] const masteryRows = (mastery?.allMastery ?? []).map((m) => ({ @@ -60,21 +76,21 @@ export async function exportDiagnosticReportToExcel(params: { return exportToExcel({ sheets: [ { - name: "报告概览", + name: t("exportContent.sheetOverview"), columns: [ - { header: "指标", key: "metric", width: 20 }, - { header: "数值", key: "value", width: 60 }, + { header: t("exportContent.metricStudent"), key: "metric", width: 20 }, + { header: "", key: "value", width: 60 }, ], rows: overviewRows, }, { - name: "知识点掌握度", + name: t("exportContent.sheetMastery"), columns: [ - { header: "知识点", key: "knowledgePoint", width: 28 }, - { header: "掌握度", key: "masteryLevel", width: 12 }, - { header: "总题数", key: "totalQuestions", width: 10 }, - { header: "正确数", key: "correctQuestions", width: 10 }, - { header: "最近评估", key: "lastAssessedAt", width: 14 }, + { header: t("exportContent.colKnowledgePoint"), key: "knowledgePoint", width: 28 }, + { header: t("exportContent.colMasteryLevel"), key: "masteryLevel", width: 12 }, + { header: t("exportContent.colTotalQuestions"), key: "totalQuestions", width: 10 }, + { header: t("exportContent.colCorrectQuestions"), key: "correctQuestions", width: 10 }, + { header: t("exportContent.colLastAssessed"), key: "lastAssessedAt", width: 14 }, ], rows: masteryRows, }, @@ -83,40 +99,89 @@ export async function exportDiagnosticReportToExcel(params: { } // 班级报告(reportType === "class") - // 班级报告的 studentId 为 null,需要从 period 反查 classId 不现实, - // 这里仅导出报告概览(知识点统计需要 classId,但报告本身未存储 classId)。 - // 如需导出班级明细,应通过 generateClassDiagnosticReport 时记录 classId。 + // v4-P2-1: 利用 classId 字段查询班级掌握度,导出知识点统计+需关注学生明细 + const classSummary = report.classId ? await getClassMasterySummary(report.classId) : null + const overviewRows = [ - { metric: "报告类型", value: "班级报告" }, - { metric: "报告周期", value: periodLabel }, - { metric: "综合得分", value: overallScore }, - { metric: "报告状态", value: report.status }, - { metric: "生成人", value: report.generatedByName ?? "-" }, - { metric: "生成时间", value: report.createdAt.split("T")[0] }, - { metric: "摘要", value: summary }, - { metric: "强项", value: strengths }, - { metric: "弱项", value: weaknesses }, - { metric: "建议", value: recommendations }, + { metric: t("exportContent.metricReportType"), value: t("type.class") }, + ...(classSummary ? [{ metric: t("exportContent.metricClass"), value: classSummary.className }] : []), + { metric: t("exportContent.metricPeriod"), value: periodLabel }, + { metric: t("exportContent.metricScore"), value: overallScore }, + ...(classSummary ? [{ metric: t("exportContent.metricStudentCount"), value: classSummary.studentCount }] : []), + ...(classSummary ? [{ metric: t("exportContent.metricAttentionCount"), value: classSummary.studentsNeedingAttention.length }] : []), + { metric: t("exportContent.metricStatus"), value: report.status }, + { metric: t("exportContent.metricGeneratedBy"), value: report.generatedByName ?? "-" }, + { metric: t("exportContent.metricCreatedAt"), value: report.createdAt.split("T")[0] }, + { metric: t("exportContent.metricSummary"), value: summary }, + { metric: t("exportContent.metricStrengths"), value: strengths }, + { metric: t("exportContent.metricWeaknesses"), value: weaknesses }, + { metric: t("exportContent.metricRecommendations"), value: recommendations }, ] - return exportToExcel({ - sheets: [ - { - name: "报告概览", - columns: [ - { header: "指标", key: "metric", width: 20 }, - { header: "数值", key: "value", width: 60 }, - ], - rows: overviewRows, - }, - ], - }) + const sheets: Array<{ + name: string + columns: Array<{ header: string; key: string; width: number }> + rows: Array> + }> = [ + { + name: t("exportContent.sheetOverview"), + columns: [ + { header: t("exportContent.metricStudent"), key: "metric", width: 20 }, + { header: "", key: "value", width: 60 }, + ], + rows: overviewRows, + }, + ] + + // v4-P2-1: 知识点统计 Sheet + if (classSummary && classSummary.knowledgePointStats.length > 0) { + const classStatsRows = classSummary.knowledgePointStats.map((kp) => ({ + knowledgePoint: kp.knowledgePointName, + averageMastery: kp.averageMastery.toFixed(1), + masteredCount: kp.masteredCount, + notMasteredCount: kp.notMasteredCount, + totalStudents: kp.totalStudents, + })) + sheets.push({ + name: t("exportContent.sheetClassStats"), + columns: [ + { header: t("exportContent.colKnowledgePoint"), key: "knowledgePoint", width: 28 }, + { header: t("exportContent.colAverageMastery"), key: "averageMastery", width: 14 }, + { header: t("exportContent.colMasteredCount"), key: "masteredCount", width: 16 }, + { header: t("exportContent.colNotMasteredCount"), key: "notMasteredCount", width: 16 }, + { header: t("exportContent.colTotalStudents"), key: "totalStudents", width: 12 }, + ], + rows: classStatsRows, + }) + } + + // v4-P2-1: 需关注学生 Sheet + if (classSummary && classSummary.studentsNeedingAttention.length > 0) { + const attentionRows = classSummary.studentsNeedingAttention.map((s) => ({ + studentName: s.studentName, + averageMastery: s.averageMastery.toFixed(1), + weakCount: s.weakCount, + })) + sheets.push({ + name: t("exportContent.sheetAttentionStudents"), + columns: [ + { header: t("exportContent.colStudentName"), key: "studentName", width: 24 }, + { header: t("exportContent.colAverageMastery"), key: "averageMastery", width: 14 }, + { header: t("exportContent.colWeakCount"), key: "weakCount", width: 12 }, + ], + rows: attentionRows, + }) + } + + return exportToExcel({ sheets }) } /** * 生成诊断报告导出文件名。 */ -export function buildDiagnosticReportFilename(period: string | null): string { +export async function buildDiagnosticReportFilename(period: string | null): Promise { + const t = await getTranslations("diagnostic.exportContent") const safePeriod = (period ?? "report").replace(/[\\/:*?"<>|]/g, "_") - return `诊断报告_${safePeriod}_${formatDateForFile()}.xlsx` + const date = formatDateForFile() + return t("filename", { period: safePeriod, date }) } diff --git a/src/modules/diagnostic/role-config.ts b/src/modules/diagnostic/role-config.ts new file mode 100644 index 0000000..4cd1cd7 --- /dev/null +++ b/src/modules/diagnostic/role-config.ts @@ -0,0 +1,40 @@ +/** + * 学情诊断模块角色配置(v4-P2-2) + * + * 通过配置驱动角色差异,新增角色只需在此添加配置项, + * 无需修改组件 props 传递逻辑。 + */ + +export type DiagnosticRole = "student" | "teacher" | "parent" + +export interface DiagnosticRoleConfig { + /** + * "练习"按钮跳转基础路径。 + * - 学生视角:跳转到学生作业页,支持 kp 参数筛选 + * - 教师视角:跳转到题目库,支持 kp 参数筛选 + * - 家长视角:null 表示隐藏练习按钮(家长无练习入口) + * + * 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。 + */ + practiceHrefBase: string | null +} + +export const DIAGNOSTIC_ROLE_CONFIG: Record = { + student: { + practiceHrefBase: "/student/learning/assignments", + }, + teacher: { + practiceHrefBase: "/teacher/questions", + }, + parent: { + practiceHrefBase: null, + }, +} + +/** + * 获取指定角色的诊断模块配置。 + * 新增角色时只需在 DIAGNOSTIC_ROLE_CONFIG 中添加配置项。 + */ +export function getDiagnosticRoleConfig(role: DiagnosticRole): DiagnosticRoleConfig { + return DIAGNOSTIC_ROLE_CONFIG[role] +} diff --git a/src/modules/diagnostic/schema.ts b/src/modules/diagnostic/schema.ts index 57e1f4e..821603b 100644 --- a/src/modules/diagnostic/schema.ts +++ b/src/modules/diagnostic/schema.ts @@ -16,6 +16,14 @@ export const GenerateClassReportSchema = z.object({ export type GenerateClassReportInput = z.infer +/** v4-P2-3: 生成年级诊断报告 */ +export const GenerateGradeReportSchema = z.object({ + gradeId: z.string().min(1), + period: z.string().min(1), +}) + +export type GenerateGradeReportInput = z.infer + /** 发布诊断报告 */ export const PublishReportSchema = z.object({ id: z.string().min(1), diff --git a/src/modules/diagnostic/services/default-diagnostic-service.ts b/src/modules/diagnostic/services/default-diagnostic-service.ts new file mode 100644 index 0000000..9496160 --- /dev/null +++ b/src/modules/diagnostic/services/default-diagnostic-service.ts @@ -0,0 +1,86 @@ +"use client" + +import type { ActionState } from "@/shared/types/action-state" + +import { + generateStudentReportAction, + generateClassReportAction, + generateGradeReportAction, + publishReportAction, + deleteReportAction, + exportDiagnosticReportAction, + getClassStudentsByKnowledgePointAction, +} from "../actions" +import type { + DiagnosticService, + ExportResult, + KnowledgePointStudent, +} from "./diagnostic-service" + +/** + * v2-P1-4: 诊断模块默认服务实现。 + * + * 绑定现有 Server Actions,作为 DiagnosticServiceProvider 的默认注入值。 + * 测试时可替换为 mock 实现以隔离组件测试。 + */ +export const defaultDiagnosticService: DiagnosticService = { + async generateStudentReport( + studentId: string, + period: string, + ): Promise> { + const formData = new FormData() + formData.set("studentId", studentId) + formData.set("period", period) + return generateStudentReportAction(null, formData) + }, + + async generateClassReport( + classId: string, + period: string, + ): Promise> { + const formData = new FormData() + formData.set("classId", classId) + formData.set("period", period) + return generateClassReportAction(null, formData) + }, + + async generateGradeReport( + gradeId: string, + period: string, + ): Promise> { + const formData = new FormData() + formData.set("gradeId", gradeId) + formData.set("period", period) + return generateGradeReportAction(null, formData) + }, + + async publishReport(id: string): Promise> { + const formData = new FormData() + formData.set("id", id) + const result = await publishReportAction(null, formData) + return { success: result.success, message: result.message } + }, + + async deleteReport(id: string): Promise> { + const formData = new FormData() + formData.set("id", id) + const result = await deleteReportAction(null, formData) + return { success: result.success, message: result.message } + }, + + async exportReport(reportId: string): Promise> { + return exportDiagnosticReportAction(reportId) + }, + + async getClassStudentsByKp( + classId: string, + knowledgePointId: string, + threshold?: number, + ): Promise> { + return getClassStudentsByKnowledgePointAction({ + classId, + knowledgePointId, + threshold, + }) + }, +} diff --git a/src/modules/diagnostic/services/diagnostic-monitor-context.tsx b/src/modules/diagnostic/services/diagnostic-monitor-context.tsx new file mode 100644 index 0000000..3e91e08 --- /dev/null +++ b/src/modules/diagnostic/services/diagnostic-monitor-context.tsx @@ -0,0 +1,58 @@ +"use client" + +/** + * v2-P2-7: 诊断模块监控埋点 Context。 + * + * 通过 React Context 注入 DiagnosticMonitor 实现, + * 使组件可通过 useDiagnosticMonitor() 获取监控实例, + * 而不直接依赖具体埋点 SDK。 + * + * 默认值为 noopDiagnosticMonitor(不发送任何事件), + * 确保未注入 Provider 时业务流程不受影响。 + * + * 用法: + * ```tsx + * + * + * + * + * + * ``` + */ + +import { createContext, useContext, type ReactNode } from "react" + +import { + noopDiagnosticMonitor, + type DiagnosticMonitor, +} from "./diagnostic-monitor" + +const DiagnosticMonitorContext = createContext( + noopDiagnosticMonitor, +) + +interface DiagnosticMonitorProviderProps { + /** 监控实现(默认使用 noop,生产环境注入真实实现) */ + monitor: DiagnosticMonitor + children: ReactNode +} + +export function DiagnosticMonitorProvider({ + monitor, + children, +}: DiagnosticMonitorProviderProps): ReactNode { + return ( + + {children} + + ) +} + +/** + * 获取当前注入的 DiagnosticMonitor 实例。 + * + * 若未注入 Provider,返回 no-op 实现,确保调用安全。 + */ +export function useDiagnosticMonitor(): DiagnosticMonitor { + return useContext(DiagnosticMonitorContext) +} diff --git a/src/modules/diagnostic/services/diagnostic-monitor.ts b/src/modules/diagnostic/services/diagnostic-monitor.ts new file mode 100644 index 0000000..07f7456 --- /dev/null +++ b/src/modules/diagnostic/services/diagnostic-monitor.ts @@ -0,0 +1,91 @@ +/** + * v2-P2-7: 诊断模块监控埋点接口。 + * + * 这是一个预留的扩展点,用于追踪诊断模块的关键操作。 + * 默认实现为 no-op(不发送任何事件),生产环境可通过 + * DiagnosticMonitorProvider 注入真实实现(如发送到 Sentry、PostHog、 + * Mixpanel、自建埋点系统等)。 + * + * 设计原则: + * - 接口与实现解耦:组件依赖接口,不依赖具体埋点 SDK。 + * - 不阻塞主流程:埋点失败不应影响业务操作。 + * - 客户端与服务端均可使用:事件类型设计为通用,避免环境耦合。 + * + * 用法: + * ```tsx + * + * + * + * + * + * ``` + */ + +/** 诊断模块可追踪的事件名称 */ +export type DiagnosticEventName = + | "report_generated" + | "report_published" + | "report_deleted" + | "report_exported" + | "class_kp_filtered" + +/** 诊断报告类型(用于事件属性) */ +export type DiagnosticReportType = "individual" | "class" | "grade" + +/** 事件属性(按事件名称区分的可选字段) */ +export interface DiagnosticEventProperties { + /** 报告类型(report_generated 事件必填) */ + reportType?: DiagnosticReportType + /** 报告 ID(publish/delete/export 事件必填) */ + reportId?: string + /** 学生 ID(individual 报告) */ + studentId?: string + /** 班级 ID(class 报告或 class_kp_filtered 事件) */ + classId?: string + /** 年级 ID(grade 报告) */ + gradeId?: string + /** 知识点 ID(class_kp_filtered 事件) */ + knowledgePointId?: string + /** 报告周期(report_generated 事件,格式 YYYY-MM) */ + period?: string + /** 掌握度阈值(class_kp_filtered 事件) */ + threshold?: number + /** 操作是否成功(便于计算转化率) */ + success?: boolean + /** 错误信息(失败时记录,便于排查) */ + error?: string + /** 操作耗时(毫秒,便于性能监控) */ + durationMs?: number + /** 触发操作的用户 ID */ + userId?: string +} + +/** + * 诊断模块监控接口。 + * + * 所有方法均为 async(即使 no-op 也返回 Promise), + * 以便真实实现可异步发送事件而不阻塞调用方。 + */ +export interface DiagnosticMonitor { + /** + * 追踪诊断模块事件。 + * + * @param eventName 事件名称 + * @param properties 事件属性(可选) + */ + track( + eventName: DiagnosticEventName, + properties?: DiagnosticEventProperties, + ): Promise +} + +/** + * 默认 no-op 实现:不发送任何事件,仅静默返回。 + * + * 在未注入真实监控实现时使用,确保业务流程不受影响。 + */ +export const noopDiagnosticMonitor: DiagnosticMonitor = { + async track(): Promise { + // no-op: 默认不发送任何事件 + }, +} diff --git a/src/modules/diagnostic/services/diagnostic-service-context.tsx b/src/modules/diagnostic/services/diagnostic-service-context.tsx new file mode 100644 index 0000000..6af750d --- /dev/null +++ b/src/modules/diagnostic/services/diagnostic-service-context.tsx @@ -0,0 +1,46 @@ +"use client" + +import { createContext, useContext, type ReactNode } from "react" + +import type { DiagnosticService } from "./diagnostic-service" + +/** + * v2-P1-4: 诊断模块服务 Context。 + * + * 组件通过 useDiagnosticService() 获取服务实现, + * 而非直接 import actions,实现依赖反转。 + * + * 默认由 DefaultDiagnosticServiceProvider 注入真实实现(调用 Server Actions); + * 测试时可注入 mock 实现以隔离组件测试。 + */ +const DiagnosticServiceContext = createContext(null) + +interface DiagnosticServiceProviderProps { + service: DiagnosticService + children: ReactNode +} + +export function DiagnosticServiceProvider({ + service, + children, +}: DiagnosticServiceProviderProps): ReactNode { + return ( + + {children} + + ) +} + +/** + * 获取诊断模块服务。 + * 必须在 DiagnosticServiceProvider 内部使用。 + */ +export function useDiagnosticService(): DiagnosticService { + const service = useContext(DiagnosticServiceContext) + if (!service) { + throw new Error( + "useDiagnosticService must be used within a DiagnosticServiceProvider", + ) + } + return service +} diff --git a/src/modules/diagnostic/services/diagnostic-service.ts b/src/modules/diagnostic/services/diagnostic-service.ts new file mode 100644 index 0000000..c25034e --- /dev/null +++ b/src/modules/diagnostic/services/diagnostic-service.ts @@ -0,0 +1,58 @@ +/** + * v2-P1-4: 诊断模块数据服务接口。 + * + * 通过 TypeScript 接口抽象所有客户端可调用的诊断操作, + * 使组件依赖接口而非具体 Server Action 实现,便于测试与替换。 + * + * 默认实现绑定现有 Server Actions;测试时可注入 mock 实现。 + */ + +import type { ActionState } from "@/shared/types/action-state" + +/** 按知识点筛选学生返回项 */ +export interface KnowledgePointStudent { + studentId: string + studentName: string + masteryLevel: number + totalQuestions: number + correctQuestions: number + lastAssessedAt: string | null + needsAttention: boolean +} + +/** 导出结果 */ +export interface ExportResult { + buffer: string + filename: string +} + +/** + * 诊断模块客户端服务接口。 + * 所有客户端组件通过 useDiagnosticService() 获取实现,不直接 import actions。 + */ +export interface DiagnosticService { + /** 生成学生个人诊断报告 */ + generateStudentReport(studentId: string, period: string): Promise> + + /** 生成班级诊断报告 */ + generateClassReport(classId: string, period: string): Promise> + + /** 生成年级诊断报告 */ + generateGradeReport(gradeId: string, period: string): Promise> + + /** 发布诊断报告 */ + publishReport(id: string): Promise> + + /** 删除诊断报告 */ + deleteReport(id: string): Promise> + + /** 导出诊断报告为 Excel(返回 base64 buffer + 文件名) */ + exportReport(reportId: string): Promise> + + /** 按知识点筛选班级学生掌握度 */ + getClassStudentsByKp( + classId: string, + knowledgePointId: string, + threshold?: number, + ): Promise> +} diff --git a/src/modules/diagnostic/services/monitored-diagnostic-service.ts b/src/modules/diagnostic/services/monitored-diagnostic-service.ts new file mode 100644 index 0000000..381b94e --- /dev/null +++ b/src/modules/diagnostic/services/monitored-diagnostic-service.ts @@ -0,0 +1,158 @@ +"use client" + +/** + * v2-P2-7: 带监控埋点的诊断服务工厂。 + * + * 通过组合模式包装任意 DiagnosticService 实现, + * 在关键操作前后调用 DiagnosticMonitor.track() 发送埋点事件。 + * + * 设计要点: + * - 不修改被包装的服务实现,仅添加监控层。 + * - 埋点失败不阻断业务流程(catch 后静默)。 + * - 记录操作耗时(durationMs)便于性能监控。 + * - 通过组合而非继承实现,符合"组合优先"原则。 + * + * 用法: + * ```tsx + * const monitoredService = createMonitoredDiagnosticService( + * defaultDiagnosticService, + * noopDiagnosticMonitor, + * ) + * ``` + */ + +import type { ActionState } from "@/shared/types/action-state" + +import type { + DiagnosticService, + ExportResult, + KnowledgePointStudent, +} from "./diagnostic-service" +import type { + DiagnosticMonitor, + DiagnosticEventName, + DiagnosticEventProperties, +} from "./diagnostic-monitor" + +/** + * 包装 DiagnosticService,在每个操作前后发送监控事件。 + * + * @param service 被包装的原始服务实现 + * @param monitor 监控实现(no-op 或真实埋点 SDK) + */ +export function createMonitoredDiagnosticService( + service: DiagnosticService, + monitor: DiagnosticMonitor, +): DiagnosticService { + /** + * 执行操作并追踪事件。 + * 埋点失败不阻断业务流程。 + */ + const withTracking = async ( + eventName: DiagnosticEventName, + properties: DiagnosticEventProperties, + operation: () => Promise>, + ): Promise> => { + const start = Date.now() + let result: ActionState + try { + result = await operation() + } catch (e) { + // 埋点:记录失败 + try { + await monitor.track(eventName, { + ...properties, + success: false, + error: e instanceof Error ? e.message : String(e), + durationMs: Date.now() - start, + }) + } catch { + // 埋点失败不阻断 + } + throw e + } + // 埋点:记录成功或失败 + try { + await monitor.track(eventName, { + ...properties, + success: result.success, + error: result.success ? undefined : result.message, + durationMs: Date.now() - start, + }) + } catch { + // 埋点失败不阻断 + } + return result + } + + return { + async generateStudentReport( + studentId: string, + period: string, + ): Promise> { + return withTracking( + "report_generated", + { reportType: "individual", studentId, period }, + () => service.generateStudentReport(studentId, period), + ) + }, + + async generateClassReport( + classId: string, + period: string, + ): Promise> { + return withTracking( + "report_generated", + { reportType: "class", classId, period }, + () => service.generateClassReport(classId, period), + ) + }, + + async generateGradeReport( + gradeId: string, + period: string, + ): Promise> { + return withTracking( + "report_generated", + { reportType: "grade", gradeId, period }, + () => service.generateGradeReport(gradeId, period), + ) + }, + + async publishReport(id: string): Promise> { + return withTracking( + "report_published", + { reportId: id }, + () => service.publishReport(id), + ) + }, + + async deleteReport(id: string): Promise> { + return withTracking( + "report_deleted", + { reportId: id }, + () => service.deleteReport(id), + ) + }, + + async exportReport(reportId: string): Promise> { + return withTracking( + "report_exported", + { reportId: reportId }, + () => service.exportReport(reportId), + ) + }, + + async getClassStudentsByKp( + classId: string, + knowledgePointId: string, + threshold?: number, + ): Promise> { + return withTracking( + "class_kp_filtered", + { classId, knowledgePointId, threshold }, + () => service.getClassStudentsByKp(classId, knowledgePointId, threshold), + ) + }, + } +} diff --git a/src/modules/diagnostic/stats-service.ts b/src/modules/diagnostic/stats-service.ts index 58db9b3..b91a3c1 100644 --- a/src/modules/diagnostic/stats-service.ts +++ b/src/modules/diagnostic/stats-service.ts @@ -8,6 +8,7 @@ import type { ClassMasterySummary, + GradeMasterySummary, KnowledgePointMastery, KnowledgePointStat, MasteryWithKnowledgePoint, @@ -273,6 +274,50 @@ export function buildClassMasterySummary( } } +/** + * v4-P2-3: Build GradeMasterySummary from raw data. + * Same aggregation logic as buildClassMasterySummary, but returns GradeMasterySummary type. + */ +export function buildGradeMasterySummary( + gradeId: string, + gradeName: string, + students: Array<{ id: string; name: string | null }>, + masteryRows: RawClassMasteryRow[], +): GradeMasterySummary { + const studentIds = students.map((s) => s.id) + const { byKp, byStudent } = aggregateClassMastery(masteryRows, studentIds) + const knowledgePointStats = computeKpStats(byKp) + const averageMastery = computeClassAverageMastery(students, byStudent) + const studentsNeedingAttention = buildStudentsNeedingAttention(students, byStudent) + + return { + gradeId, + gradeName, + studentCount: students.length, + averageMastery, + knowledgePointStats, + studentsNeedingAttention, + } +} + +/** + * Translation strings needed for report content generation. + * Actions layer builds this from next-intl and passes it in, + * keeping stats-service free of i18n framework dependencies. + */ +export interface ReportContentTranslations { + studentSummary: (vars: { studentName: string; period: string; score: number; total: number; strengths: number; weaknesses: number }) => string + studentRecommendation: (vars: { kpName: string; level: number }) => string + studentNoWeakness: string + classSummary: (vars: { className: string; period: string; score: number; students: number; attention: number }) => string + classRecommendation: (vars: { kpName: string; level: number }) => string + classNoWeakness: string + /** v4-P2-3: 年级报告翻译 */ + gradeSummary: (vars: { gradeName: string; period: string; score: number; students: number; attention: number }) => string + gradeRecommendation: (vars: { kpName: string; level: number }) => string + gradeNoWeakness: string +} + /** * Build student report content (strengths/weaknesses/recommendations/summary) * from a StudentMasterySummary. @@ -280,6 +325,7 @@ export function buildClassMasterySummary( export function buildStudentReportContent( summary: StudentMasterySummary, period: string, + translations: ReportContentTranslations, ): { summaryText: string strengths: string[] @@ -295,14 +341,23 @@ export function buildStudentReportContent( (m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`, ) const recommendations = summary.weaknesses.map( - (m) => - `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`, + (m) => translations.studentRecommendation({ + kpName: m.knowledgePointName, + level: m.masteryLevel, + }), ) if (recommendations.length === 0) { - recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。") + recommendations.push(translations.studentNoWeakness) } - const summaryText = `学生 ${summary.studentName} 在 ${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。` + const summaryText = translations.studentSummary({ + studentName: summary.studentName, + period, + score: overallScore, + total: summary.totalKnowledgePoints, + strengths: strengths.length, + weaknesses: weaknesses.length, + }) return { summaryText, strengths, weaknesses, recommendations, overallScore } } @@ -314,6 +369,7 @@ export function buildStudentReportContent( export function buildClassReportContent( summary: ClassMasterySummary, period: string, + translations: ReportContentTranslations, ): { summaryText: string strengths: string[] @@ -334,14 +390,76 @@ export function buildClassReportContent( (k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`, ) const recommendations = topWeak.map( - (k) => - `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`, + (k) => translations.classRecommendation({ + kpName: k.knowledgePointName, + level: k.averageMastery, + }), ) if (recommendations.length === 0) { - recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。") + recommendations.push(translations.classNoWeakness) } - const summaryText = `班级 ${summary.className} 在 ${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。` + const summaryText = translations.classSummary({ + className: summary.className, + period, + score: summary.averageMastery, + students: summary.studentCount, + attention: summary.studentsNeedingAttention.length, + }) + + return { + summaryText, + strengths, + weaknesses, + recommendations, + overallScore: summary.averageMastery, + } +} + +/** + * v4-P2-3: Build grade report content (strengths/weaknesses/recommendations/summary) + * from a GradeMasterySummary. Strengths and weaknesses are limited to top 5. + */ +export function buildGradeReportContent( + summary: GradeMasterySummary, + period: string, + translations: ReportContentTranslations, +): { + summaryText: string + strengths: string[] + weaknesses: string[] + recommendations: string[] + overallScore: number +} { + const topWeak = summary.knowledgePointStats + .filter((k) => k.averageMastery < WEAKNESS_THRESHOLD) + .sort((a, b) => a.averageMastery - b.averageMastery) + .slice(0, 5) + const strengths = summary.knowledgePointStats + .filter((k) => k.averageMastery >= STRENGTH_THRESHOLD) + .sort((a, b) => b.averageMastery - a.averageMastery) + .slice(0, 5) + .map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`) + const weaknesses = topWeak.map( + (k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`, + ) + const recommendations = topWeak.map( + (k) => translations.gradeRecommendation({ + kpName: k.knowledgePointName, + level: k.averageMastery, + }), + ) + if (recommendations.length === 0) { + recommendations.push(translations.gradeNoWeakness) + } + + const summaryText = translations.gradeSummary({ + gradeName: summary.gradeName, + period, + score: summary.averageMastery, + students: summary.studentCount, + attention: summary.studentsNeedingAttention.length, + }) return { summaryText, diff --git a/src/modules/diagnostic/types.ts b/src/modules/diagnostic/types.ts index f666fb7..925900d 100644 --- a/src/modules/diagnostic/types.ts +++ b/src/modules/diagnostic/types.ts @@ -39,6 +39,8 @@ export interface DiagnosticReport { studentId: string | null /** v4-P1-4: 班级报告关联的 classId(个人报告为 null) */ classId: string | null + /** v4-P2-3: 年级报告关联的 gradeId(个人/班级报告为 null) */ + gradeId: string | null generatedBy: string | null reportType: DiagnosticReportType period: string | null @@ -73,6 +75,21 @@ export interface ClassMasterySummary { }> } +/** v4-P2-3: 年级掌握度摘要(结构同 ClassMasterySummary,但标识为年级) */ +export interface GradeMasterySummary { + gradeId: string + gradeName: string + studentCount: number + averageMastery: number + knowledgePointStats: KnowledgePointStat[] + studentsNeedingAttention: Array<{ + studentId: string + studentName: string + averageMastery: number + weakCount: number + }> +} + /** 知识点统计 */ export interface KnowledgePointStat { knowledgePointId: string diff --git a/src/modules/elective/actions.ts b/src/modules/elective/actions.ts index ddc9faf..61c5191 100644 --- a/src/modules/elective/actions.ts +++ b/src/modules/elective/actions.ts @@ -23,15 +23,25 @@ import { openSelection, closeSelection, } from "./data-access" -import { runLottery, selectCourse, dropCourse } from "./data-access-operations" +import { + runLottery, + selectCourse, + dropCourse, + ElectiveBusinessError, + type ElectiveErrorCode, +} from "./data-access-operations" +import { COURSE_SELECTION_STATUS_LABEL_KEYS } from "./constants" const revalidateElectivePaths = (id?: string) => { revalidatePath("/admin/elective") revalidatePath("/teacher/elective") revalidatePath("/student/elective") + revalidatePath("/parent/elective") if (id) { revalidatePath(`/admin/elective/${id}`) revalidatePath(`/admin/elective/${id}/edit`) + revalidatePath(`/teacher/elective/${id}`) + revalidatePath(`/teacher/elective/${id}/edit`) } } @@ -41,6 +51,24 @@ const requireCourseId = (formData: FormData): string => { return id } +/** + * 将 ElectiveBusinessError 翻译为用户可见的 i18n 文案。 + * 在 catch 中调用,返回 null 表示非业务错误(交给 handleActionError)。 + */ +async function translateBusinessError( + e: unknown, + t: Awaited> +): Promise { + if (e instanceof ElectiveBusinessError) { + const code = e.code satisfies ElectiveErrorCode + if (e.params) { + return t(`errors.${code}`, e.params) + } + return t(`errors.${code}`) + } + return null +} + /** * 校验当前用户对课程的管理权限(资源归属校验)。 * - admin(scope=all):直接放行 @@ -101,6 +129,9 @@ export async function createElectiveCourseAction( }) return { success: true, message: t("messages.created"), data: id } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -153,6 +184,9 @@ export async function updateElectiveCourseAction( }) return { success: true, message: t("messages.updated"), data: id } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -181,6 +215,9 @@ export async function deleteElectiveCourseAction( }) return { success: true, message: t("messages.deleted") } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -209,6 +246,9 @@ export async function openSelectionAction( }) return { success: true, message: t("messages.selectionOpened") } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -237,6 +277,9 @@ export async function closeSelectionAction( }) return { success: true, message: t("messages.selectionClosed") } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -279,6 +322,9 @@ export async function runLotteryAction( data: result, } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -310,8 +356,13 @@ export async function selectCourseAction( targetType: "course_selection", properties: { status: result.status, priority: parsed.data.priority }, }) - return { success: true, message: result.message, data: result.status } + // 通过 i18n 翻译选课结果状态(result.status 已是 CourseSelectionStatus 类型) + const statusKey = COURSE_SELECTION_STATUS_LABEL_KEYS[result.status] + return { success: true, message: t(statusKey), data: result.status } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } @@ -333,7 +384,7 @@ export async function dropCourseAction( errors: parsed.error.flatten().fieldErrors, } } - await dropCourse(parsed.data.courseId, ctx.userId) + await dropCourse(parsed.data.courseId, ctx.userId, parsed.data.dropReason) revalidateElectivePaths(parsed.data.courseId) await trackEvent({ event: "elective.course_dropped", @@ -343,6 +394,9 @@ export async function dropCourseAction( }) return { success: true, message: t("messages.courseDropped") } } catch (e) { + const t = await getTranslations("elective") + const translated = await translateBusinessError(e, t) + if (translated) return { success: false, message: translated } return handleActionError(e) } } diff --git a/src/modules/elective/components/elective-course-detail.tsx b/src/modules/elective/components/elective-course-detail.tsx new file mode 100644 index 0000000..0f52c06 --- /dev/null +++ b/src/modules/elective/components/elective-course-detail.tsx @@ -0,0 +1,198 @@ +import Link from "next/link" +import { useTranslations } from "next-intl" +import { ArrowLeft, Pencil, Users } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" + +import { + COURSE_SELECTION_STATUS_BADGE_VARIANTS, + COURSE_SELECTION_STATUS_LABEL_KEYS, + ELECTIVE_STATUS_BADGE_VARIANTS, + ELECTIVE_STATUS_LABEL_KEYS, + SELECTION_MODE_LABEL_KEYS, +} from "../constants" +import type { + CourseSelectionWithDetails, + ElectiveCourseWithDetails, +} from "../types" + +/** + * 选修课程详情视图(admin/teacher 共用)。 + * + * 设计原则: + * - 通过 props 注入数据,不直接调用 data-access(便于测试与复用) + * - 组合优先:分成课程信息卡片 + 选课名单表格两个独立区块 + * - 编辑按钮通过 editHref 参数化,admin/teacher 路由各自传入 + */ +export function ElectiveCourseDetail({ + course, + selections, + editHref, + backHref, + showEditButton = true, +}: { + course: ElectiveCourseWithDetails + selections: CourseSelectionWithDetails[] + editHref?: string + backHref: string + showEditButton?: boolean +}) { + const t = useTranslations("elective") + + const activeSelections = selections.filter((s) => + ["selected", "enrolled", "waitlist"].includes(s.status) + ) + + return ( +
+
+ + {showEditButton && editHref ? ( + + ) : null} +
+ + + +
+ {course.name} +

+ {t("description.detail")} +

+
+ + {t(ELECTIVE_STATUS_LABEL_KEYS[course.status])} + +
+ +
+ + + + + + + + + + + +
+ {course.schedule ? ( +
+

{t("fields.schedule")}

+

{course.schedule}

+
+ ) : null} + {course.description ? ( +
+

{t("fields.description")}

+

{course.description}

+
+ ) : null} +
+
+ + + +
+ + + {t("detail.studentsTitle")} + + + {activeSelections.length} + +
+
+ + {activeSelections.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {activeSelections.map((sel, idx) => ( + + + + + + + + + ))} + +
#{t("detail.studentName")}{t("export.statusHeader")}{t("detail.priority")}{t("detail.selectedAt")}{t("detail.enrolledAt")}
{idx + 1} + {sel.studentName ?? "—"} + + + {t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])} + + + {sel.priority ?? "—"} + + {sel.selectedAt + ? new Date(sel.selectedAt).toLocaleDateString() + : "—"} + + {sel.enrolledAt + ? new Date(sel.enrolledAt).toLocaleDateString() + : "—"} +
+
+ )} +
+
+
+ ) +} + +function DetailField({ + label, + value, +}: { + label: string + value: string | null | undefined +}) { + return ( +
+

{label}

+

{value ?? "—"}

+
+ ) +} diff --git a/src/modules/elective/components/elective-course-form.tsx b/src/modules/elective/components/elective-course-form.tsx index fc9f4f9..8007523 100644 --- a/src/modules/elective/components/elective-course-form.tsx +++ b/src/modules/elective/components/elective-course-form.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" +import Link from "next/link" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -69,20 +70,21 @@ export function ElectiveCourseForm({ : null if (!res) { - toast.error("Invalid form state") + toast.error(t("form.invalidFormState")) return } if (res.success) { toast.success(res.message) + // 根据 backHref 推断返回列表页路径 const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective" router.push(redirectBase) router.refresh() } else { - toast.error(res.message || "Failed to save course") + toast.error(res.message || t("form.saveFailed")) } } catch { - toast.error("Failed to save course") + toast.error(t("form.saveFailed")) } finally { setIsWorking(false) } @@ -92,14 +94,14 @@ export function ElectiveCourseForm({ - {mode === "create" ? "New Elective Course" : "Edit Elective Course"} + {mode === "create" ? t("form.createTitle") : t("form.editTitle")}
- +
- + - + {grades.map((g) => ( @@ -143,15 +145,15 @@ export function ElectiveCourseForm({
- +
- +
- +
- +
- +
- +
- +
- +
+ + {/* P2-4:退课截止时间 */} +
+ + +

+ {t("form.dropDeadlineHint")} +

+
- +