feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components

dashboard:

- Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter

- Add parent-dashboard components directory

- Add config, hooks, and services directories

diagnostic:

- Add role-config and services directory

elective:

- Add elective-course-detail, elective-stats-cards, parent-selection-view components

- Add data-access-settings and data-access-stats
This commit is contained in:
SpecialX
2026-07-03 10:25:46 +08:00
parent dfffb61e94
commit 138b6f1b00
58 changed files with 3313 additions and 695 deletions

View File

@@ -8,10 +8,9 @@ import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClas
import { import {
getHomeworkAssignments, getHomeworkAssignments,
getHomeworkSubmissions, getHomeworkSubmissions,
getStudentDashboardGrades,
getStudentHomeworkAssignments,
getTeacherGradeTrends,
} from "@/modules/homework/data-access" } 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 { getCurrentStudentUser, getUserBasicInfo } from "@/modules/users/data-access"
import { getParentDashboardData } from "@/modules/parent/data-access" import { getParentDashboardData } from "@/modules/parent/data-access"
@@ -19,7 +18,6 @@ import { getAdminDashboardData } from "./data-access"
import type { import type {
AdminDashboardData, AdminDashboardData,
StudentDashboardProps, StudentDashboardProps,
StudentTodayScheduleItem,
TeacherDashboardData, TeacherDashboardData,
} from "./types" } from "./types"
import type { ParentDashboardData } from "@/modules/parent/types" import type { ParentDashboardData } from "@/modules/parent/types"
@@ -82,7 +80,7 @@ export async function getTeacherDashboardAction(): Promise<ActionState<TeacherDa
schedule, schedule,
assignments, assignments,
submissions, submissions,
teacherName: teacherProfile?.name ?? "Teacher", teacherName: teacherProfile?.name ?? "",
gradeTrends, gradeTrends,
metrics, metrics,
}, },
@@ -117,7 +115,7 @@ export async function getStudentDashboardAction(): Promise<ActionState<{
const now = new Date() const now = new Date()
const stats = countStudentAssignments(assignments, now) const stats = countStudentAssignments(assignments, now)
const todayWeekday = toWeekday(now) const todayWeekday = toWeekday(now)
const todayScheduleItems = filterTodaySchedule<StudentTodayScheduleItem>(schedule, todayWeekday) const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday)
const upcomingAssignments = sortUpcomingAssignments(assignments, 6) const upcomingAssignments = sortUpcomingAssignments(assignments, 6)
return { return {

View File

@@ -16,6 +16,7 @@ import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Skeleton } from "@/shared/components/ui/skeleton" import { Skeleton } from "@/shared/components/ui/skeleton"
import { DashboardSection } from "../dashboard-section" import { DashboardSection } from "../dashboard-section"
import { DashboardTimeRangeFilter } from "../dashboard-time-range-filter"
import type { AdminDashboardStreams } from "../../streams" import type { AdminDashboardStreams } from "../../streams"
import { import {
AdminContentCard, AdminContentCard,
@@ -62,10 +63,15 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
} }
/> />
<DashboardSection variant="stats"> <DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
<AdminStatsBar t={t} streams={streams} /> <AdminStatsBar t={t} streams={streams} />
</DashboardSection> </DashboardSection>
{/* L2: 时间范围筛选器 — 当前为 UI 占位,趋势数据接入后生效 */}
<div className="flex justify-end">
<DashboardTimeRangeFilter />
</div>
{/* 快捷操作 — 纯静态,无需数据获取 */} {/* 快捷操作 — 纯静态,无需数据获取 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<QuickActionCard <QuickActionCard
@@ -106,23 +112,23 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
/> />
</div> </div>
<DashboardSection variant="chart"> <DashboardSection variant="chart" ariaLabel={t("sections.trends")}>
<AdminTrendCharts t={t} /> <AdminTrendCharts t={t} />
</DashboardSection> </DashboardSection>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("sections.userRoles")}>
<AdminUserRolesCard t={t} streams={streams} /> <AdminUserRolesCard t={t} streams={streams} />
</DashboardSection> </DashboardSection>
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("sections.content")}>
<AdminContentCard t={t} streams={streams} /> <AdminContentCard t={t} streams={streams} />
</DashboardSection> </DashboardSection>
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("sections.homeworkActivity")}>
<AdminHomeworkActivityCard t={t} streams={streams} /> <AdminHomeworkActivityCard t={t} streams={streams} />
</DashboardSection> </DashboardSection>
</div> </div>
<DashboardSection variant="table"> <DashboardSection variant="table" ariaLabel={t("sections.recentUsers")}>
<AdminRecentUsersTable t={t} streams={streams} /> <AdminRecentUsersTable t={t} streams={streams} />
</DashboardSection> </DashboardSection>
</div> </div>

View File

@@ -35,10 +35,10 @@ export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams:
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.users")} value={usersStats.userCount} icon={Users} valueClassName="tabular-nums" /> <StatCard title={t("stats.users")} value={usersStats.userCount} icon={Users} color="text-blue-500" valueClassName="tabular-nums" href="/admin/users" />
<StatCard title={t("stats.classes")} value={classesStats.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" /> <StatCard title={t("stats.classes")} value={classesStats.classCount} icon={LayoutDashboard} color="text-emerald-500" valueClassName="tabular-nums" href="/admin/school/classes" />
<StatCard title={t("stats.homeworkPublished")} value={homeworkStats.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" /> <StatCard title={t("stats.homeworkPublished")} value={homeworkStats.homeworkAssignmentPublishedCount} icon={ClipboardList} color="text-purple-500" valueClassName="tabular-nums" href="/admin/homework/assignments" />
<StatCard title={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" /> <StatCard title={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={FileText} color="text-amber-500" valueClassName="tabular-nums" href="/admin/homework/submissions?status=submitted" />
</div> </div>
) )
} }
@@ -77,10 +77,10 @@ export function AdminContentCard({ t, streams }: { t: TranslationFunction; strea
<CardTitle>{t("sections.content")}</CardTitle> <CardTitle>{t("sections.content")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
<ContentRow label={t("stats.textbooks")} value={textbooksStats.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.textbooks")} value={textbooksStats.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} href="/admin/textbooks" />
<ContentRow label={t("stats.chapters")} value={textbooksStats.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.chapters")} value={textbooksStats.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} href="/admin/textbooks" />
<ContentRow label={t("stats.questions")} value={questionsStats.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.questions")} value={questionsStats.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} href="/admin/questions" />
<ContentRow label={t("stats.exams")} value={examsStats.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.exams")} value={examsStats.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} href="/admin/exams" />
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -97,9 +97,9 @@ export function AdminHomeworkActivityCard({ t, streams }: { t: TranslationFuncti
<CardTitle>{t("sections.homeworkActivity")}</CardTitle> <CardTitle>{t("sections.homeworkActivity")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
<ContentRow label={t("stats.totalAssignments")} value={homeworkStats.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.totalAssignments")} value={homeworkStats.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} href="/admin/homework/assignments" />
<ContentRow label={t("stats.totalSubmissions")} value={homeworkStats.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.totalSubmissions")} value={homeworkStats.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} href="/admin/homework/submissions" />
<ContentRow label={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} href="/admin/homework/submissions?status=submitted" />
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -134,6 +134,8 @@ export function AdminUserRolesCard({ t, streams }: { t: TranslationFunction; str
// ─── 趋势图表 ────────────────────────────────────────────── // ─── 趋势图表 ──────────────────────────────────────────────
export function AdminTrendCharts({ t }: { t: TranslationFunction }) { export function AdminTrendCharts({ t }: { t: TranslationFunction }) {
// TODO(V4-P3-2): 趋势数据待接入真实统计查询(见 data-access.ts P2-4 TODO
// 当前 data-access.getAdminDashboardData 返回空数组,此处渲染空状态
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
@@ -214,12 +216,14 @@ function ContentRow({
label, label,
value, value,
icon, icon,
href,
}: { }: {
label: string label: string
value: number value: number
icon: React.ReactNode icon: React.ReactNode
href?: string
}) { }) {
return ( const content = (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{icon} {icon}
@@ -228,4 +232,14 @@ function ContentRow({
<div className="text-sm font-medium tabular-nums">{value}</div> <div className="text-sm font-medium tabular-nums">{value}</div>
</div> </div>
) )
if (href) {
return (
<Link href={href} className="block rounded-md hover:bg-muted/50 transition-colors -mx-1 px-1 py-0.5">
{content}
</Link>
)
}
return content
} }

View File

@@ -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 (
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
config.color,
className,
)}
role="status"
aria-label={`Change: ${absPercent}% ${trend}`}
>
<Icon className="h-3 w-3" aria-hidden />
{absPercent}%
</span>
)
}

View File

@@ -25,7 +25,7 @@ export async function DashboardGreetingHeader({
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0"> <div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">
{t(`greeting.${greetingKey}`)}{userName} {userName ? `${t(`greeting.${greetingKey}`)}${userName}` : t(`greeting.${greetingKey}`)}
</h2> </h2>
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p> <p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
</div> </div>

View File

@@ -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"
/**
* 仪表盘通知中心 WidgetL5
*
* 集成到仪表盘侧边栏,显示最近通知摘要。
* 完整通知下拉由 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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden />
{t("sections.notifications")}
{unreadCount > 0 && (
<Badge variant="default" className="tabular-nums">
{unreadCount}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{notifications.length === 0 ? (
<EmptyState
title={t("empty.noNotifications")}
description={t("empty.noNotificationsDesc")}
className="h-auto border-none shadow-none"
/>
) : (
<ul className="space-y-1" role="list">
{notifications.slice(0, 5).map((n) => (
<li key={n.id}>
<Link
href={n.href ?? "#"}
className="group flex items-start justify-between gap-2 rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{!n.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" />
)}
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
{n.title}
</span>
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{n.body}</p>
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
</Link>
</li>
))}
</ul>
)}
<div className="flex justify-end pt-3">
<Button asChild variant="ghost" size="sm">
<Link href={viewAllHref}>
{t("sections.viewAllNotifications")}
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<div className={cn("flex flex-col gap-4 lg:grid", className)}>
{mobileFirstSlot && (
<div className="order-first lg:hidden">{mobileFirstSlot}</div>
)}
{children}
</div>
)
}
/**
* 移动端水平滑动卡片容器L8
*
* - snap-x snap-mandatory 提供卡片吸附效果
* - 隐藏滚动条但保留滚动功能
* - 仅在移动端生效,桌面端转为网格
*/
export function MobileSwipeContainer({
children,
className,
ariaLabel,
}: {
children: ReactNode
className?: string
ariaLabel?: string
}) {
return (
<div
className={cn(
"flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory scrollbar-hide sm:hidden",
className,
)}
aria-label={ariaLabel}
role="region"
>
{children}
</div>
)
}
/**
* 桌面端网格容器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 (
<div className={cn("hidden sm:grid gap-4", gridCols, className)}>
{children}
</div>
)
}

View File

@@ -1,57 +1,13 @@
"use client" "use client"
import { Component, type ReactNode, Suspense } from "react" import { type ReactNode, Suspense } from "react"
import { AlertCircle } from "lucide-react" import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton" import { Skeleton } from "@/shared/components/ui/skeleton"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
/**
* 仪表盘分区 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 <DashboardSectionErrorFallback onRetry={this.handleRetry} />
}
return this.props.children
}
}
function DashboardSectionErrorFallback({
onRetry,
}: {
onRetry: () => void
}): ReactNode {
const t = useTranslations("dashboard.error")
return (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{ label: t("retry"), onClick: onRetry }}
className="h-auto border-none shadow-none"
/>
)
}
/** /**
* 分区骨架屏变体 * 分区骨架屏变体
@@ -148,23 +104,53 @@ export function DashboardSectionSkeleton({
* 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。 * 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。
* 单个区块出错或加载中时,仅影响该区块,不波及整页。 * 单个区块出错或加载中时,仅影响该区块,不波及整页。
* *
* 使用共享 SectionErrorBoundary 替代模块特定的 DashboardSectionErrorBoundary 类。
*
* V4P3-1新增 `ariaLabel` prop传入时渲染 `<section role="region" tabIndex={0}>`
* 使键盘用户可按逻辑顺序遍历各 Widget提升 a11y。
*
* @example * @example
* <DashboardSection variant="stats"> * <DashboardSection variant="stats" ariaLabel={t("sections.userStats")}>
* <TeacherStats ... /> * <TeacherStats ... />
* </DashboardSection> * </DashboardSection>
*/ */
export function DashboardSection({ export function DashboardSection({
children, children,
variant = "card", variant = "card",
ariaLabel,
}: { }: {
children: ReactNode children: ReactNode
variant?: SkeletonVariant variant?: SkeletonVariant
/** 传入时渲染为可聚焦的 region提升键盘导航 a11y */
ariaLabel?: string
}): ReactNode { }): ReactNode {
return ( const t = useTranslations("dashboard.error")
<DashboardSectionErrorBoundary>
const fallback = (): ReactNode => (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{ label: t("retry"), onClick: () => window.location.reload() }}
className="h-auto border-none shadow-none"
/>
)
const content = (
<SectionErrorBoundary namespace="common" fallback={fallback}>
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}> <Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
{children} {children}
</Suspense> </Suspense>
</DashboardSectionErrorBoundary> </SectionErrorBoundary>
) )
if (ariaLabel) {
return (
<section role="region" aria-label={ariaLabel} tabIndex={0} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg">
{content}
</section>
)
}
return content
} }

View File

@@ -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 (
<div className={cn("inline-flex items-center gap-1 rounded-lg border bg-muted/40 p-1", className)} role="radiogroup" aria-label={t("label")}>
{RANGE_OPTIONS.map(({ value, icon: Icon }) => (
<Button
key={value}
variant={currentRange === value ? "default" : "ghost"}
size="sm"
className="gap-1.5"
onClick={() => handleRangeChange(value)}
role="radio"
aria-checked={currentRange === value}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
{t(value)}
</Button>
))}
</div>
)
}

View File

@@ -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 (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
<div className="text-sm text-muted-foreground">
{t(`greeting.${greetingKey}`)}
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
</div>
</div>
{hasChildren ? (
<>
{attentionBannerSlot}
<nav
aria-label={t("quickActions.announcements")}
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
>
{QUICK_ENTRIES.map((entry) => (
<Link
key={entry.href}
href={entry.href}
className="group"
aria-label={entry.label}
>
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
<entry.icon
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
aria-hidden
/>
<span className="text-sm font-medium">{entry.label}</span>
</CardContent>
</Card>
</Link>
))}
</nav>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" aria-hidden />
<span>
{t("badge.childrenLinked", { count: childrenCount })}
</span>
</div>
{childrenSlot}
{aiSummarySlot}
</>
) : (
<EmptyState
icon={Users}
title={t("empty.noChildren")}
description={t("empty.noChildrenDesc")}
className="border-none shadow-none"
action={{
label: t("empty.contactSupport"),
href: "/messages",
}}
/>
)}
</div>
)
}

View File

@@ -59,7 +59,7 @@ async function StudentDashboardBody({
<StudentDashboardHeader studentName={student.name} /> <StudentDashboardHeader studentName={student.name} />
</header> </header>
<DashboardSection variant="stats"> <DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
<StudentStatsGrid <StudentStatsGrid
enrolledClassCount={dashboardProps.enrolledClassCount} enrolledClassCount={dashboardProps.enrolledClassCount}
dueSoonCount={dashboardProps.dueSoonCount} dueSoonCount={dashboardProps.dueSoonCount}
@@ -74,15 +74,15 @@ async function StudentDashboardBody({
aria-label={t("sections.upcomingAssignments")} aria-label={t("sections.upcomingAssignments")}
className="lg:col-span-2 space-y-6" className="lg:col-span-2 space-y-6"
> >
<DashboardSection variant="list"> <DashboardSection variant="list" ariaLabel={t("sections.upcomingAssignments")}>
<StudentUpcomingAssignmentsCard upcomingAssignments={dashboardProps.upcomingAssignments} /> <StudentUpcomingAssignmentsCard upcomingAssignments={dashboardProps.upcomingAssignments} />
</DashboardSection> </DashboardSection>
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("sections.grades")}>
<StudentGradesCard grades={dashboardProps.grades} /> <StudentGradesCard grades={dashboardProps.grades} />
</DashboardSection> </DashboardSection>
</section> </section>
<aside aria-label={t("sections.todaySchedule")} className="space-y-6"> <aside aria-label={t("sections.todaySchedule")} className="space-y-6">
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("sections.todaySchedule")}>
<StudentTodayScheduleCard items={dashboardProps.todayScheduleItems} /> <StudentTodayScheduleCard items={dashboardProps.todayScheduleItems} />
</DashboardSection> </DashboardSection>
</aside> </aside>

View File

@@ -12,13 +12,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { useCurrentTime } from "@/shared/hooks" import { useCurrentTime } from "@/shared/hooks"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { timeToMinutes } from "@/modules/dashboard/lib/dashboard-utils"
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types" 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[] }) { export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
const t = useTranslations("dashboard") const t = useTranslations("dashboard")
const hasSchedule = items.length > 0 const hasSchedule = items.length > 0

View File

@@ -9,40 +9,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatusBadge } from "@/shared/components/ui/status-badge" import { StatusBadge } from "@/shared/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate, cn } from "@/shared/lib/utils" 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 type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
import { import {
STUDENT_HOMEWORK_PROGRESS_VARIANT, STUDENT_HOMEWORK_PROGRESS_VARIANT,
STUDENT_HOMEWORK_PROGRESS_LABEL, STUDENT_HOMEWORK_PROGRESS_LABEL,
} from "@/modules/homework/types" } 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[] }) { export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
const t = await getTranslations("dashboard") const t = await getTranslations("dashboard")
const locale = await getLocale() const locale = await getLocale()
const hasAssignments = upcomingAssignments.length > 0 const hasAssignments = upcomingAssignments.length > 0
const now = new Date()
return ( return (
<Card> <Card>
@@ -78,7 +56,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{upcomingAssignments.map((a) => { {upcomingAssignments.map((a) => {
const urgency = getDueUrgency(a.dueAt) const urgency = getDueUrgency(a.dueAt, now)
const isGraded = a.progressStatus === "graded" const isGraded = a.progressStatus === "graded"
return ( return (

View File

@@ -64,7 +64,7 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
<TeacherDashboardHeader teacherName={data.teacherName} /> <TeacherDashboardHeader teacherName={data.teacherName} />
</header> </header>
<DashboardSection variant="stats"> <DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
<TeacherStats <TeacherStats
toGradeCount={metrics.toGradeCount} toGradeCount={metrics.toGradeCount}
activeAssignmentsCount={metrics.activeAssignmentsCount} activeAssignmentsCount={metrics.activeAssignmentsCount}
@@ -76,19 +76,19 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12"> <div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
{/* 课表:移动端首位,桌面端右上 — 仅渲染一次P2-9 修复,原为双实例) */} {/* 课表:移动端首位,桌面端右上 — 仅渲染一次P2-9 修复,原为双实例) */}
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1"> <div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1">
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("sections.todaySchedule")}>
<TeacherSchedule items={metrics.todayScheduleItems} /> <TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection> </DashboardSection>
</div> </div>
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2"> <section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2">
<DashboardSection variant="card"> <DashboardSection variant="card" ariaLabel={t("todo.title")}>
<TeacherTodoCard items={todoItems} /> <TeacherTodoCard items={todoItems} />
</DashboardSection> </DashboardSection>
<DashboardSection variant="chart"> <DashboardSection variant="chart" ariaLabel={t("sections.gradeTrends")}>
<TeacherGradeTrends trends={data.gradeTrends} /> <TeacherGradeTrends trends={data.gradeTrends} />
</DashboardSection> </DashboardSection>
<DashboardSection variant="list"> <DashboardSection variant="list" ariaLabel={t("sections.recentSubmissions")}>
<RecentSubmissions <RecentSubmissions
submissions={metrics.submissionsToGrade} submissions={metrics.submissionsToGrade}
title={t("sections.pendingGrading")} title={t("sections.pendingGrading")}
@@ -99,10 +99,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
</section> </section>
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2"> <aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2">
<DashboardSection variant="list"> <DashboardSection variant="list" ariaLabel={t("sections.homework")}>
<TeacherHomeworkCard assignments={data.assignments} /> <TeacherHomeworkCard assignments={data.assignments} />
</DashboardSection> </DashboardSection>
<DashboardSection variant="list"> <DashboardSection variant="list" ariaLabel={t("sections.myClasses")}>
<TeacherClassesCard classes={data.classes} /> <TeacherClassesCard classes={data.classes} />
</DashboardSection> </DashboardSection>
</aside> </aside>

View File

@@ -6,35 +6,13 @@ import { CalendarDays, CalendarX, MapPin } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { getScheduleStatus } from "@/modules/dashboard/lib/dashboard-utils"
type TeacherTodayScheduleItem = { import type { TeacherTodayScheduleItem } from "@/modules/dashboard/types"
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) { export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
const t = await getTranslations("dashboard") const t = await getTranslations("dashboard")
const hasSchedule = items.length > 0 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 ( return (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -60,7 +38,7 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" /> <div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
{items.map((item, index) => { {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 isLive = status === "live"
const isPast = status === "past" const isPast = status === "past"
const isLast = index === items.length - 1 const isLast = index === items.length - 1

View File

@@ -27,6 +27,7 @@ export async function TeacherStats({
href="/teacher/homework/submissions?status=submitted" href="/teacher/homework/submissions?status=submitted"
highlight={toGradeCount > 0} highlight={toGradeCount > 0}
color="text-amber-500" color="text-amber-500"
valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title={t("stats.activeAssignments")} title={t("stats.activeAssignments")}
@@ -35,6 +36,7 @@ export async function TeacherStats({
icon={PenTool} icon={PenTool}
href="/teacher/homework/assignments?status=published" href="/teacher/homework/assignments?status=published"
color="text-blue-500" color="text-blue-500"
valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title={t("stats.averageScore")} title={t("stats.averageScore")}
@@ -43,6 +45,7 @@ export async function TeacherStats({
icon={TrendingUp} icon={TrendingUp}
href="#grade-trends" href="#grade-trends"
color="text-emerald-500" color="text-emerald-500"
valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title={t("stats.submissionRate")} title={t("stats.submissionRate")}
@@ -51,6 +54,7 @@ export async function TeacherStats({
icon={BarChart} icon={BarChart}
href="#grade-trends" href="#grade-trends"
color="text-purple-500" color="text-purple-500"
valueClassName="tabular-nums"
/> />
</div> </div>
) )

View File

@@ -21,6 +21,13 @@ const VARIANT_STYLES: Record<TeacherTodoItem["variant"], { icon: typeof AlertCir
info: { icon: CalendarCheck, iconColor: "text-blue-500", badge: "bg-blue-500 text-white" }, info: { icon: CalendarCheck, iconColor: "text-blue-500", badge: "bg-blue-500 text-white" },
} }
/** 变体优先级映射数值越小优先级越高V4P3-3优化排序可读性 */
const VARIANT_PRIORITY: Record<TeacherTodoItem["variant"], number> = {
urgent: 0,
normal: 1,
info: 2,
}
export async function TeacherTodoCard({ items }: TeacherTodoCardProps) { export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
const t = await getTranslations("dashboard") const t = await getTranslations("dashboard")
const hasItems = items.some((item) => item.count > 0) const hasItems = items.some((item) => item.count > 0)
@@ -49,11 +56,7 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
<div className="space-y-1"> <div className="space-y-1">
{items {items
.filter((item) => item.count > 0) .filter((item) => item.count > 0)
.sort((a, b) => { .sort((a, b) => VARIANT_PRIORITY[a.variant] - VARIANT_PRIORITY[b.variant])
if (a.variant === "urgent" && b.variant !== "urgent") return -1
if (a.variant !== "urgent" && b.variant === "urgent") return 1
return 0
})
.map((item, idx) => { .map((item, idx) => {
const style = VARIANT_STYLES[item.variant] const style = VARIANT_STYLES[item.variant]
const Icon = style.icon const Icon = style.icon

View File

@@ -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
}
}

View File

@@ -43,6 +43,8 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<Ad
homeworkSubmissionCount: homeworkStats.homeworkSubmissionCount, homeworkSubmissionCount: homeworkStats.homeworkSubmissionCount,
homeworkSubmissionToGradeCount: homeworkStats.homeworkSubmissionToGradeCount, homeworkSubmissionToGradeCount: homeworkStats.homeworkSubmissionToGradeCount,
recentUsers: usersStats.recentUsers, recentUsers: usersStats.recentUsers,
// TODO(V4-P2-4): 接入真实趋势数据统计查询(按日期聚合用户注册数和作业提交数)
// 当前为占位空数组AdminTrendCharts 组件会渲染空状态
userGrowth: [], userGrowth: [],
homeworkTrend: [], homeworkTrend: [],
} }

View File

@@ -0,0 +1,82 @@
"use client"
import { useCallback, useState } from "react"
import type { DashboardRole, DashboardWidgetConfig } from "@/modules/dashboard/types"
import { getWidgetConfig } from "@/modules/dashboard/config/widget-configs"
const STORAGE_KEY = "dashboard-widget-preferences"
/** 用户 Widget 偏好widgetId → visible */
export type WidgetPreferences = Record<string, boolean>
/**
* 从 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 {}
}
}
/**
* 仪表盘自定义偏好 HookL4
*
* 从 localStorage 读取用户对 Widget 显示/隐藏的偏好,
* 与默认配置合并,支持运行时切换。
*
* - 首次访问返回默认配置的 `defaultVisible`
* - 用户切换后持久化到 localStorage
* - 支持重置为默认
*/
export function useDashboardPreferences(role: DashboardRole) {
// 使用 lazy initializer 避免 useEffect 中调用 setState
const [preferences, setPreferences] = useState<WidgetPreferences>(() => 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,
}
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useEffect, useState } from "react"
/**
* 仪表盘实时更新 HookL6
*
* 基于 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<T = unknown>(
url: string | null,
eventName = "update",
): {
status: ConnectionStatus
lastMessage: T | null
lastUpdatedAt: Date | null
} {
const [status, setStatus] = useState<ConnectionStatus>("connecting")
const [lastMessage, setLastMessage] = useState<T | null>(null)
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null)
useEffect(() => {
if (!url) {
setStatus("closed")
return
}
let eventSource: EventSource | null = null
let retryCount = 0
let retryTimer: ReturnType<typeof setTimeout> | 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 }
}

View File

@@ -98,14 +98,14 @@ export function sortUpcomingAssignments(
/** /**
* 从课表中筛选指定周几的课程,按开始时间升序排序。 * 从课表中筛选指定周几的课程,按开始时间升序排序。
* *
* 泛型 T 允许调用方指定返回的课表项类型(StudentTodayScheduleItem * `StudentTodayScheduleItem` 与 `TeacherTodayScheduleItem` 结构完全相同,
* TeacherTodayScheduleItem)。两者结构完全相同,泛型仅用于类型层面 * 返回 `StudentTodayScheduleItem[]` 可通过结构化类型赋值给任一类型变量
*/ */
export function filterTodaySchedule<T extends StudentTodayScheduleItem | TeacherTodayScheduleItem = StudentTodayScheduleItem | TeacherTodayScheduleItem>( export function filterTodaySchedule(
schedule: readonly ClassScheduleItem[], schedule: readonly ClassScheduleItem[],
weekday: Weekday, weekday: Weekday,
classNameById?: ReadonlyMap<string, string>, classNameById?: ReadonlyMap<string, string>,
): T[] { ): StudentTodayScheduleItem[] {
return schedule return schedule
.filter((s) => s.weekday === weekday) .filter((s) => s.weekday === weekday)
.sort((a, b) => a.startTime.localeCompare(b.startTime)) .sort((a, b) => a.startTime.localeCompare(b.startTime))
@@ -117,7 +117,7 @@ export function filterTodaySchedule<T extends StudentTodayScheduleItem | Teacher
startTime: s.startTime, startTime: s.startTime,
endTime: s.endTime, endTime: s.endTime,
location: s.location ?? null, location: s.location ?? null,
})) as T[] }))
} }
/** 教师仪表盘派生指标 */ /** 教师仪表盘派生指标 */
@@ -144,7 +144,7 @@ export function computeTeacherMetrics(
const todayWeekday = toWeekday(now) const todayWeekday = toWeekday(now)
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const)) const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
const todayScheduleItems = filterTodaySchedule<TeacherTodayScheduleItem>( const todayScheduleItems = filterTodaySchedule(
schedule, schedule,
todayWeekday, todayWeekday,
classNameById, classNameById,
@@ -196,3 +196,84 @@ export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" {
/** 重导出 TeacherDashboardData 便于 actions 使用 */ /** 重导出 TeacherDashboardData 便于 actions 使用 */
export type { TeacherDashboardData } 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"
}

View File

@@ -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<AdminDashboardStreams>
/** 获取教师仪表盘数据 */
getTeacherData(): Promise<ActionState<TeacherDashboardData & { metrics: TeacherDashboardMetrics }>>
/** 获取学生仪表盘数据 */
getStudentData(): Promise<ActionState<{
student: { id: string; name: string } | null
dashboardProps: Omit<StudentDashboardProps, "studentName"> | null
}>>
/** 获取家长仪表盘数据 */
getParentData(): Promise<ActionState<{
data: ParentDashboardData | null
hasChildren: boolean
}>>
}
/** 管理员仪表盘流式数据源(各分区独立 Promise */
export interface AdminDashboardStreams {
usersStats: Promise<UsersDashboardStats>
classesStats: Promise<ClassesDashboardStats>
textbooksStats: Promise<TextbooksDashboardStats>
questionsStats: Promise<QuestionsDashboardStats>
examsStats: Promise<ExamsDashboardStats>
homeworkStats: Promise<HomeworkDashboardStats>
}
// ─── 监控埋点接口 ──────────────────────────────────────────
/**
* 仪表盘监控埋点接口。
*
* 预留关键操作埋点,供后续接入实际监控 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<DashboardService | null>(null)
const DashboardAnalyticsContext = createContext<DashboardAnalytics>(noopAnalytics)
/** 仪表盘服务 Provider在页面层注入角色特定的实现 */
export function DashboardServiceProvider({
service,
analytics,
children,
}: {
service: DashboardService
analytics?: DashboardAnalytics
children: ReactNode
}): ReactNode {
return (
<DashboardServiceContext.Provider value={service}>
<DashboardAnalyticsContext.Provider value={analytics ?? noopAnalytics}>
{children}
</DashboardAnalyticsContext.Provider>
</DashboardServiceContext.Provider>
)
}
/** 获取当前注入的仪表盘数据服务 */
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)
}

View File

@@ -72,3 +72,36 @@ export type TeacherDashboardData = {
teacherName: string teacherName: string
gradeTrends: TeacherGradeTrendItem[] 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[]
}

View File

@@ -12,6 +12,7 @@ import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
import { import {
generateDiagnosticReport, generateDiagnosticReport,
generateClassDiagnosticReport, generateClassDiagnosticReport,
generateGradeDiagnosticReport,
publishDiagnosticReport, publishDiagnosticReport,
deleteDiagnosticReport, deleteDiagnosticReport,
getDiagnosticReportById, getDiagnosticReportById,
@@ -24,6 +25,7 @@ import {
import { import {
GenerateStudentReportSchema, GenerateStudentReportSchema,
GenerateClassReportSchema, GenerateClassReportSchema,
GenerateGradeReportSchema,
PublishReportSchema, PublishReportSchema,
DeleteReportSchema, DeleteReportSchema,
} from "./schema" } from "./schema"
@@ -80,6 +82,32 @@ export async function generateClassReportAction(
} }
} }
/** v4-P2-3: 生成年级诊断报告 */
export async function generateGradeReportAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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( export async function publishReportAction(
prevState: ActionState<string> | null, prevState: ActionState<string> | null,
@@ -126,7 +154,7 @@ export async function publishReportAction(
try { try {
await createNotification({ await createNotification({
userId: studentId, userId: studentId,
type: "grade", type: "diagnostic",
title, title,
content, content,
link, link,
@@ -144,7 +172,7 @@ export async function publishReportAction(
try { try {
await createNotification({ await createNotification({
userId: parentId, userId: parentId,
type: "grade", type: "diagnostic",
title, title,
content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。", content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。",
link: "/parent/diagnostic", link: "/parent/diagnostic",
@@ -208,7 +236,7 @@ export async function exportDiagnosticReportAction(
} }
const buffer = await exportDiagnosticReportToExcel({ reportId }) const buffer = await exportDiagnosticReportToExcel({ reportId })
const filename = buildDiagnosticReportFilename(report.period) const filename = await buildDiagnosticReportFilename(report.period)
return { return {
success: true, success: true,

View File

@@ -30,7 +30,8 @@ import {
} from "@/shared/components/ui/table" } from "@/shared/components/ui/table"
import { usePermission } from "@/shared/hooks" import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions" 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" import type { ClassMasterySummary } from "../types"
interface ClassDiagnosticViewProps { interface ClassDiagnosticViewProps {
@@ -60,6 +61,8 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
const router = useRouter() const router = useRouter()
const { hasPermission } = usePermission() const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) 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 [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
@@ -71,10 +74,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!summary) return if (!summary) return
setIsGenerating(true) setIsGenerating(true)
const formData = new FormData() const result = await service.generateClassReport(summary.classId, period)
formData.set("classId", summary.classId)
formData.set("period", period)
const result = await generateClassReportAction(null, formData)
setIsGenerating(false) setIsGenerating(false)
if (result.success) { if (result.success) {
toast.success(result.message) toast.success(result.message)
@@ -86,7 +86,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
/** /**
* v3-P2-5: 按知识点筛选学生。 * v3-P2-5: 按知识点筛选学生。
* 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。 * 选择知识点后调用服务获取该知识点上所有学生的掌握度。
*/ */
const handleKpFilter = async (kpId: string) => { const handleKpFilter = async (kpId: string) => {
setSelectedKpId(kpId) setSelectedKpId(kpId)
@@ -96,10 +96,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
} }
setIsFiltering(true) setIsFiltering(true)
try { try {
const result = await getClassStudentsByKnowledgePointAction({ const result = await service.getClassStudentsByKp(
classId: summary.classId, summary.classId,
knowledgePointId: kpId, kpId,
}) )
if (result.success && result.data) { if (result.success && result.data) {
setFilteredStudents(result.data) setFilteredStudents(result.data)
} else { } else {
@@ -127,43 +127,46 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 概览 */} {/* v2-P1-6: 概览区块独立 Error Boundary */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <WidgetBoundary title={t("summary.class")} skeletonHeight={140}>
<Card> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<CardHeader className="pb-2"> <Card>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
<CardContent> </CardHeader>
<p className="text-2xl font-bold">{summary.className}</p> <CardContent>
</CardContent> <p className="text-2xl font-bold">{summary.className}</p>
</Card> </CardContent>
<Card> </Card>
<CardHeader className="pb-2"> <Card>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
<CardContent> </CardHeader>
<p className="text-2xl font-bold">{summary.studentCount}</p> <CardContent>
</CardContent> <p className="text-2xl font-bold">{summary.studentCount}</p>
</Card> </CardContent>
<Card> </Card>
<CardHeader className="pb-2"> <Card>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
<CardContent> </CardHeader>
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p> <CardContent>
</CardContent> <p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
</Card> </CardContent>
<Card> </Card>
<CardHeader className="pb-2"> <Card>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
<CardContent> </CardHeader>
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p> <CardContent>
</CardContent> <p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
</Card> </CardContent>
</div> </Card>
</div>
</WidgetBoundary>
{/* 知识点掌握度热力图 */} {/* v2-P1-6: 知识点掌握度热力图区块独立 Error Boundary */}
<WidgetBoundary title={t("chart.heatmapTitle")} skeletonHeight={300}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -181,7 +184,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<> <>
<div <div
className="flex flex-wrap gap-2" className="flex flex-wrap gap-2"
role="img" role="group"
aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })} aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })}
> >
{summary.knowledgePointStats.map((kp) => { {summary.knowledgePointStats.map((kp) => {
@@ -189,9 +192,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
return ( return (
<div <div
key={kp.knowledgePointId} key={kp.knowledgePointId}
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`} tabIndex={0}
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white outline-none transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${masteryColor(kp.averageMastery)}`}
role="img" role="img"
aria-label={`${kp.knowledgePointName}${kp.averageMastery.toFixed(1)}%${levelLabel}${kp.masteredCount}/${kp.totalStudents}`} aria-label={t("classDiagnostic.heatmapCellAriaLabel", {
name: kp.knowledgePointName,
level: kp.averageMastery.toFixed(1),
label: levelLabel,
mastered: kp.masteredCount,
total: kp.totalStudents,
})}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (${kp.masteredCount}/${kp.totalStudents})`} title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (${kp.masteredCount}/${kp.totalStudents})`}
> >
<span className="max-w-32 truncate text-xs font-medium"> <span className="max-w-32 truncate text-xs font-medium">
@@ -230,8 +240,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
{/* v3-P2-5: 按知识点筛选学生 */} {/* v2-P1-6: 按知识点筛选学生区块独立 Error Boundary */}
<WidgetBoundary title={t("classDiagnostic.filterByKpTitle")} skeletonHeight={200}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -316,8 +328,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
{/* 知识点排名表 */} {/* v2-P1-6: 知识点排名表区块独立 Error Boundary */}
<WidgetBoundary title={t("chart.rankingTitle")} skeletonHeight={240}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t("chart.rankingTitle")}</CardTitle> <CardTitle>{t("chart.rankingTitle")}</CardTitle>
@@ -360,8 +374,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
{/* 需重点关注的学生 */} {/* v2-P1-6: 需重点关注的学生区块独立 Error Boundary */}
<WidgetBoundary title={t("classDiagnostic.studentsNeedingAttentionTitle")} skeletonHeight={240}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -411,9 +427,11 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
{/* 生成班级报告 */} {/* v2-P1-6: 生成班级报告区块独立 Error Boundary */}
{canManage ? ( {canManage ? (
<WidgetBoundary title={t("report.generateClass")} skeletonHeight={160}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -442,6 +460,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
) : null} ) : null}
</div> </div>
) )

View File

@@ -1,5 +1,6 @@
/** /**
* v4-P3-7: 诊断报告数据置信度工具。 * v4-P3-7: 诊断报告数据置信度工具。
* v2-P1-5: 改进为基于知识点数量的多级置信度计算。
* *
* 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。 * 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。
* 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。 * 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。
@@ -9,14 +10,39 @@ import type { DiagnosticReportWithDetails } from "../types"
export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient" 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
/** /**
* 根据报告数据计算置信度。 * v2-P1-5: 根据报告数据计算置信度。
* 简化方案overallScore === null 表示无数据insufficient *
* 否则视为高置信度high * 置信度基于报告中涉及的知识点数量strengths + weaknesses 数组长度之和):
* 后续可扩展为基于 totalQuestions 等数据量字段的多级判断。 * - 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" 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" return "high"
} }

View File

@@ -15,15 +15,17 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
const t = useTranslations("diagnostic") const t = useTranslations("diagnostic")
const isEmpty = !data || data.length === 0 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 const chartData = isEmpty
? [] ? []
: data.map((d) => ({ : data.map((d) => ({ ...d }))
...d,
shortName:
d.knowledgePoint.length > 8
? `${d.knowledgePoint.slice(0, 8)}...`
: d.knowledgePoint,
}))
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined) const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
@@ -52,7 +54,8 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
> >
<ComparisonRadarChart <ComparisonRadarChart
data={chartData} data={chartData}
angleKey="shortName" angleKey="knowledgePoint"
angleTickFormatter={truncateAxisLabel}
angleTickFontSize={11} angleTickFontSize={11}
domain={[0, 100]} domain={[0, 100]}
tickCount={5} tickCount={5}

View File

@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react" import { useCallback } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { FileText, Trash2, Send, Download, Share2, Copy } from "lucide-react" import { FileText, Trash2, Send, Download } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -34,12 +34,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks" import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions" import { useDiagnosticService } from "../services/diagnostic-service-context"
import type { DiagnosticReportWithDetails } from "../types" import type { DiagnosticReportWithDetails } from "../types"
import { import {
getConfidenceLevel, getConfidenceLevel,
@@ -63,10 +62,11 @@ export function ReportList({ reports }: ReportListProps) {
const { hasPermission } = usePermission() const { hasPermission } = usePermission()
const t = useTranslations("diagnostic") const t = useTranslations("diagnostic")
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
// v2-P1-4: 通过 Context 注入服务,不直接 import actions
const service = useDiagnosticService()
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [publishId, setPublishId] = useState<string | null>(null) const [publishId, setPublishId] = useState<string | null>(null)
const [shareId, setShareId] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false) const [isBusy, setIsBusy] = useState(false)
const updateParam = useCallback( const updateParam = useCallback(
@@ -85,9 +85,7 @@ export function ReportList({ reports }: ReportListProps) {
const handlePublish = async () => { const handlePublish = async () => {
if (!publishId) return if (!publishId) return
setIsBusy(true) setIsBusy(true)
const formData = new FormData() const result = await service.publishReport(publishId)
formData.set("id", publishId)
const result = await publishReportAction(null, formData)
setIsBusy(false) setIsBusy(false)
if (result.success) { if (result.success) {
toast.success(result.message) toast.success(result.message)
@@ -101,9 +99,7 @@ export function ReportList({ reports }: ReportListProps) {
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteId) return if (!deleteId) return
setIsBusy(true) setIsBusy(true)
const formData = new FormData() const result = await service.deleteReport(deleteId)
formData.set("id", deleteId)
const result = await deleteReportAction(null, formData)
setIsBusy(false) setIsBusy(false)
if (result.success) { if (result.success) {
toast.success(result.message) toast.success(result.message)
@@ -121,7 +117,7 @@ export function ReportList({ reports }: ReportListProps) {
const handleExport = async (reportId: string) => { const handleExport = async (reportId: string) => {
setIsBusy(true) setIsBusy(true)
try { try {
const result = await exportDiagnosticReportAction(reportId) const result = await service.exportReport(reportId)
if (!result.success || !result.data) { if (!result.success || !result.data) {
toast.error(result.message || t("error.exportFailed")) toast.error(result.message || t("error.exportFailed"))
return return
@@ -151,18 +147,6 @@ export function ReportList({ reports }: ReportListProps) {
} }
} }
// v3-P3-8: 复制报告分享链接到剪贴板
const handleCopyLink = async (): Promise<void> => {
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: 置信度标签与提示 // v4-P3-7: 置信度标签与提示
const confidenceLabel = (level: ConfidenceLevel): string => { const confidenceLabel = (level: ConfidenceLevel): string => {
if (level === "high") return t("reportList.confidenceHigh") if (level === "high") return t("reportList.confidenceHigh")
@@ -202,12 +186,6 @@ export function ReportList({ reports }: ReportListProps) {
return "-" 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 过滤器 */} {/* 过滤器 */}
@@ -314,20 +292,6 @@ export function ReportList({ reports }: ReportListProps) {
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
{/* v3-P3-8: 分享按钮(仅教师可见) */}
{canManage ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShareId(r.id)}
disabled={isBusy}
title={t("reportList.share")}
aria-label={t("reportList.shareAriaLabel", { studentName: r.studentName ?? "" })}
>
<Share2 className="h-4 w-4" />
</Button>
) : null}
{canManage && r.status === "draft" ? ( {canManage && r.status === "draft" ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -402,44 +366,6 @@ export function ReportList({ reports }: ReportListProps) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* v3-P3-8: 分享报告 */}
<Dialog open={shareId !== null} onOpenChange={(open) => !open && setShareId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("reportList.shareTitle")}</DialogTitle>
<DialogDescription>{t("reportList.shareDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{sharedReport?.summary ? (
<div className="rounded-md border bg-muted/50 p-3">
<p className="text-sm">{sharedReport.summary}</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="share-link" className="text-xs">{t("reportList.shareLinkLabel")}</Label>
<div className="flex gap-2">
<Input
id="share-link"
readOnly
value={shareUrl}
aria-label={t("reportList.shareLinkAriaLabel")}
className="text-sm"
/>
<Button onClick={handleCopyLink} className="shrink-0">
<Copy className="mr-1 h-4 w-4" />
{t("reportList.copyLink")}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShareId(null)}>
{t("report.cancel")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -9,6 +9,7 @@ import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { MasteryRadarChart } from "./mastery-radar-chart" import { MasteryRadarChart } from "./mastery-radar-chart"
import { import {
@@ -16,6 +17,7 @@ import {
confidenceBadgeVariant, confidenceBadgeVariant,
type ConfidenceLevel, type ConfidenceLevel,
} from "./confidence-utils" } from "./confidence-utils"
import { getDiagnosticRoleConfig, type DiagnosticRole } from "../role-config"
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
interface StudentDiagnosticViewProps { interface StudentDiagnosticViewProps {
@@ -23,11 +25,13 @@ interface StudentDiagnosticViewProps {
reports: DiagnosticReportWithDetails[] reports: DiagnosticReportWithDetails[]
classAverageMastery?: MasteryRadarPoint[] classAverageMastery?: MasteryRadarPoint[]
/** /**
* v3-P2-6: "练习"按钮的跳转基础路径 * v4-P2-2: 角色配置驱动
* - 学生视角:默认 `/student/learning/assignments` * 组件内部根据 role 查找 DIAGNOSTIC_ROLE_CONFIG 获取 practiceHrefBase 等角色差异配置。
* - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选) * 新增角色只需在 role-config.ts 中添加配置项,无需修改组件 props。
* - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口) */
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。 role?: DiagnosticRole
/**
* @deprecated v4-P2-2: 请改用 `role` prop。保留向后兼容若同时传入则 role 优先。
*/ */
practiceHrefBase?: string | null practiceHrefBase?: string | null
} }
@@ -36,9 +40,12 @@ export function StudentDiagnosticView({
summary, summary,
reports, reports,
classAverageMastery, classAverageMastery,
practiceHrefBase = "/student/learning/assignments", role = "student",
practiceHrefBase,
}: StudentDiagnosticViewProps) { }: StudentDiagnosticViewProps) {
const t = useTranslations("diagnostic") const t = useTranslations("diagnostic")
// v4-P2-2: 角色配置驱动role prop 优先于 deprecated practiceHrefBase
const resolvedPracticeHrefBase = practiceHrefBase ?? getDiagnosticRoleConfig(role).practiceHrefBase
if (!summary) { if (!summary) {
return ( return (
@@ -132,11 +139,14 @@ export function StudentDiagnosticView({
</Card> </Card>
</div> </div>
{/* 雷达图 */} {/* v2-P1-6: 雷达图区块独立 Error Boundary */}
<MasteryRadarChart data={radarData} /> <WidgetBoundary title={t("chart.radarTitle")} skeletonHeight={384}>
<MasteryRadarChart data={radarData} />
</WidgetBoundary>
{/* 强项 / 弱项 */} {/* v2-P1-6: 强项 / 弱项区块独立 Error Boundary */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <WidgetBoundary title={t("strengths.title")} skeletonHeight={300}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -179,9 +189,9 @@ export function StudentDiagnosticView({
<span className="text-sm truncate">{m.knowledgePointName}</span> <span className="text-sm truncate">{m.knowledgePointName}</span>
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge> <Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
</div> </div>
{practiceHrefBase ? ( {resolvedPracticeHrefBase ? (
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}> <Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
<Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}> <Link href={`${resolvedPracticeHrefBase}?kp=${m.knowledgePointId}`}>
{t("weaknesses.practice")} {t("weaknesses.practice")}
<ArrowRight className="ml-1 h-3 w-3" /> <ArrowRight className="ml-1 h-3 w-3" />
</Link> </Link>
@@ -194,9 +204,11 @@ export function StudentDiagnosticView({
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</WidgetBoundary>
{/* 最新报告 / 建议 */} {/* v2-P1-6: 最新报告区块独立 Error Boundary */}
{latestReport ? ( {latestReport ? (
<WidgetBoundary title={t("studentDiagnostic.diagnosticReportTitle")} skeletonHeight={200}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -245,10 +257,12 @@ export function StudentDiagnosticView({
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
) : null} ) : null}
{/* 历史报告列表 */} {/* v2-P1-6: 历史报告区块独立 Error Boundary */}
{publishedReports.length > 1 ? ( {publishedReports.length > 1 ? (
<WidgetBoundary title={t("report.history")} skeletonHeight={200}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -287,6 +301,7 @@ export function StudentDiagnosticView({
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</WidgetBoundary>
) : null} ) : null}
</div> </div>
) )

View File

@@ -3,17 +3,23 @@ import "server-only"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm" import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
import { cache } from "react" import { cache } from "react"
import { getTranslations } from "next-intl/server"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { learningDiagnosticReports } from "@/shared/db/schema" 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 { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { toNumber } from "@/modules/grades/lib/grade-utils" import { toNumber } from "@/modules/grades/lib/grade-utils"
import { BusinessError } from "@/shared/lib/action-utils" import { BusinessError } from "@/shared/lib/action-utils"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access" import { getClassMasterySummary, getGradeMasterySummary, getStudentMasterySummary } from "./data-access"
import { buildClassReportContent, buildStudentReportContent } from "./stats-service" import {
buildClassReportContent,
buildGradeReportContent,
buildStudentReportContent,
type ReportContentTranslations,
} from "./stats-service"
import type { import type {
DiagnosticReport, DiagnosticReport,
DiagnosticReportListResult, DiagnosticReportListResult,
@@ -21,6 +27,25 @@ import type {
DiagnosticReportWithDetails, DiagnosticReportWithDetails,
} from "./types" } from "./types"
/**
* Build report content translations from next-intl.
* Keeps stats-service free of i18n framework dependencies.
*/
async function getReportContentTranslations(): Promise<ReportContentTranslations> {
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 修复:结构化错误码,避免直接暴露内部错误)。 * 诊断报告业务错误P3-27 修复:结构化错误码,避免直接暴露内部错误)。
* 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。 * 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。
@@ -31,7 +56,9 @@ export class DiagnosticReportError extends BusinessError {
| "STUDENT_NOT_FOUND" | "STUDENT_NOT_FOUND"
| "NO_MASTERY_DATA" | "NO_MASTERY_DATA"
| "CLASS_NOT_FOUND" | "CLASS_NOT_FOUND"
| "CLASS_NO_MASTERY_DATA", | "CLASS_NO_MASTERY_DATA"
| "GRADE_NOT_FOUND"
| "GRADE_NO_MASTERY_DATA",
message: string, message: string,
) { ) {
super(message, code) super(message, code)
@@ -49,6 +76,7 @@ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): Diag
id: r.id, id: r.id,
studentId: r.studentId, studentId: r.studentId,
classId: r.classId, classId: r.classId,
gradeId: r.gradeId,
generatedBy: r.generatedBy, generatedBy: r.generatedBy,
reportType: r.reportType, reportType: r.reportType,
period: r.period, period: r.period,
@@ -76,8 +104,9 @@ export async function generateDiagnosticReport(
throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告") throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告")
} }
const translations = await getReportContentTranslations()
const { summaryText, strengths, weaknesses, recommendations, overallScore } = const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildStudentReportContent(summary, period) buildStudentReportContent(summary, period, translations)
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
@@ -110,8 +139,9 @@ export async function generateClassDiagnosticReport(
throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告") throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告")
} }
const translations = await getReportContentTranslations()
const { summaryText, strengths, weaknesses, recommendations, overallScore } = const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildClassReportContent(summary, period) buildClassReportContent(summary, period, translations)
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
@@ -130,6 +160,43 @@ export async function generateClassDiagnosticReport(
return id return id
} }
/** v4-P2-3: 生成年级诊断报告 */
export async function generateGradeDiagnosticReport(
gradeId: string,
period: string,
generatedBy: string
): Promise<string> {
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 修复:支持分页) */ /** 查询诊断报告列表P3-15 修复:支持分页) */
export const getDiagnosticReports = cache( export const getDiagnosticReports = cache(
async ( async (
@@ -142,14 +209,14 @@ export const getDiagnosticReports = cache(
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status)) if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period)) 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 关联) // - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null需通过 classId 关联)
// 由于当前 schema 班级报告 studentId=null无法直接按 classId 过滤,因此对 class_taught scope // 由于当前 schema 班级报告 studentId=null无法直接按 classId 过滤,因此对 class_taught scope
// 个人报告按所教班级学生 ID 过滤班级报告studentId=null保留教师可查看自己生成的班级报告 // 个人报告按所教班级学生 ID 过滤班级报告studentId=null保留教师可查看自己生成的班级报告
// - class_members: 学生角色,调用方在 filters.studentId 中传入 ctx.userId无需在此重复过滤 // - class_members: 学生角色,调用方在 filters.studentId 中传入 ctx.userId此处兜底过滤
// - children: 仅返回子女的报告 // - children: 仅返回子女的报告
// - grade_managed: 返回所辖年级所有学生的报告(通过 studentId IN 所辖年级学生) // - grade_managed: v2-P1-1 修复,返回所辖年级所有学生的报告(通过 getUserIdsByGradeId 查询年级学生 ID
// - all: 不过滤 // - all: 不过滤admin
if (scope) { if (scope) {
if (scope.type === "children") { if (scope.type === "children") {
if (scope.childrenIds.length === 0) { if (scope.childrenIds.length === 0) {
@@ -167,8 +234,21 @@ export const getDiagnosticReports = cache(
// 个人报告按学生 ID 过滤班级报告studentId=null由 generatedBy 限制为当前教师 // 个人报告按学生 ID 过滤班级报告studentId=null由 generatedBy 限制为当前教师
// 这里简化:仅返回所教班级学生的个人报告 // 这里简化:仅返回所教班级学生的个人报告
conditions.push(inArray(learningDiagnosticReports.studentId, studentIds)) 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 const whereClause = conditions.length > 0 ? and(...conditions) : undefined

View File

@@ -11,10 +11,12 @@ import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@
import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection" import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection"
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access" import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
import { getGradeNameById } from "@/modules/school/data-access"
import { import {
aggregateClassMastery, aggregateClassMastery,
buildClassMasterySummary, buildClassMasterySummary,
buildGradeMasterySummary,
buildStudentMasterySummary, buildStudentMasterySummary,
computeKpStats, computeKpStats,
computeMasteryLevel, computeMasteryLevel,
@@ -24,6 +26,7 @@ import {
} from "./stats-service" } from "./stats-service"
import type { import type {
ClassMasterySummary, ClassMasterySummary,
GradeMasterySummary,
KnowledgePointStat, KnowledgePointStat,
MasteryWithKnowledgePoint, MasteryWithKnowledgePoint,
StudentMasterySummary, StudentMasterySummary,
@@ -360,6 +363,45 @@ export const getClassMasterySummary = cache(async (classId: string): Promise<Cla
return buildClassMasterySummary(classId, className, students, rawRows) return buildClassMasterySummary(classId, className, students, rawRows)
}) })
/** v4-P2-3: 获取年级掌握度摘要 */
export const getGradeMasterySummary = cache(async (gradeId: string): Promise<GradeMasterySummary | null> => {
// 年级名称 与 学生列表 相互独立,并行拉取
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<KnowledgePointStat[]> => { export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
let studentIds: string[] = [] let studentIds: string[] = []

View File

@@ -1,11 +1,24 @@
import "server-only" import "server-only"
import { getTranslations } from "next-intl/server"
import { exportToExcel } from "@/shared/lib/excel" import { exportToExcel } from "@/shared/lib/excel"
import { formatDateForFile } from "@/shared/lib/utils" import { formatDateForFile } from "@/shared/lib/utils"
import { BusinessError } from "@/shared/lib/action-utils"
import { getDiagnosticReportById } from "./data-access-reports" import { getDiagnosticReportById } from "./data-access-reports"
import { getStudentMasterySummary, getClassMasterySummary } from "./data-access" 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。 * v3-P2-4: 导出诊断报告为 Excel。
* *
@@ -23,10 +36,13 @@ export async function exportDiagnosticReportToExcel(params: {
}): Promise<Buffer> { }): Promise<Buffer> {
const report = await getDiagnosticReportById(params.reportId) const report = await getDiagnosticReportById(params.reportId)
if (!report) { 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 overallScore = report.overallScore ?? "-"
const strengths = (report.strengths ?? []).join("\n") || "-" const strengths = (report.strengths ?? []).join("\n") || "-"
const weaknesses = (report.weaknesses ?? []).join("\n") || "-" const weaknesses = (report.weaknesses ?? []).join("\n") || "-"
@@ -37,16 +53,16 @@ export async function exportDiagnosticReportToExcel(params: {
// 个人报告 // 个人报告
const mastery = await getStudentMasterySummary(report.studentId) const mastery = await getStudentMasterySummary(report.studentId)
const overviewRows = [ const overviewRows = [
{ metric: "学生姓名", value: report.studentName ?? "-" }, { metric: t("exportContent.metricStudent"), value: report.studentName ?? "-" },
{ metric: "报告周期", value: periodLabel }, { metric: t("exportContent.metricPeriod"), value: periodLabel },
{ metric: "综合得分", value: overallScore }, { metric: t("exportContent.metricScore"), value: overallScore },
{ metric: "报告状态", value: report.status }, { metric: t("exportContent.metricStatus"), value: report.status },
{ metric: "生成人", value: report.generatedByName ?? "-" }, { metric: t("exportContent.metricGeneratedBy"), value: report.generatedByName ?? "-" },
{ metric: "生成时间", value: report.createdAt.split("T")[0] }, { metric: t("exportContent.metricCreatedAt"), value: report.createdAt.split("T")[0] },
{ metric: "摘要", value: summary }, { metric: t("exportContent.metricSummary"), value: summary },
{ metric: "强项", value: strengths }, { metric: t("exportContent.metricStrengths"), value: strengths },
{ metric: "弱项", value: weaknesses }, { metric: t("exportContent.metricWeaknesses"), value: weaknesses },
{ metric: "建议", value: recommendations }, { metric: t("exportContent.metricRecommendations"), value: recommendations },
] ]
const masteryRows = (mastery?.allMastery ?? []).map((m) => ({ const masteryRows = (mastery?.allMastery ?? []).map((m) => ({
@@ -60,21 +76,21 @@ export async function exportDiagnosticReportToExcel(params: {
return exportToExcel({ return exportToExcel({
sheets: [ sheets: [
{ {
name: "报告概览", name: t("exportContent.sheetOverview"),
columns: [ columns: [
{ header: "指标", key: "metric", width: 20 }, { header: t("exportContent.metricStudent"), key: "metric", width: 20 },
{ header: "数值", key: "value", width: 60 }, { header: "", key: "value", width: 60 },
], ],
rows: overviewRows, rows: overviewRows,
}, },
{ {
name: "知识点掌握度", name: t("exportContent.sheetMastery"),
columns: [ columns: [
{ header: "知识点", key: "knowledgePoint", width: 28 }, { header: t("exportContent.colKnowledgePoint"), key: "knowledgePoint", width: 28 },
{ header: "掌握度", key: "masteryLevel", width: 12 }, { header: t("exportContent.colMasteryLevel"), key: "masteryLevel", width: 12 },
{ header: "总题数", key: "totalQuestions", width: 10 }, { header: t("exportContent.colTotalQuestions"), key: "totalQuestions", width: 10 },
{ header: "正确数", key: "correctQuestions", width: 10 }, { header: t("exportContent.colCorrectQuestions"), key: "correctQuestions", width: 10 },
{ header: "最近评估", key: "lastAssessedAt", width: 14 }, { header: t("exportContent.colLastAssessed"), key: "lastAssessedAt", width: 14 },
], ],
rows: masteryRows, rows: masteryRows,
}, },
@@ -83,40 +99,89 @@ export async function exportDiagnosticReportToExcel(params: {
} }
// 班级报告reportType === "class" // 班级报告reportType === "class"
// 班级报告的 studentId 为 null需要从 period 反查 classId 不现实, // v4-P2-1: 利用 classId 字段查询班级掌握度,导出知识点统计+需关注学生明细
// 这里仅导出报告概览(知识点统计需要 classId但报告本身未存储 classId const classSummary = report.classId ? await getClassMasterySummary(report.classId) : null
// 如需导出班级明细,应通过 generateClassDiagnosticReport 时记录 classId。
const overviewRows = [ const overviewRows = [
{ metric: "报告类型", value: "班级报告" }, { metric: t("exportContent.metricReportType"), value: t("type.class") },
{ metric: "报告周期", value: periodLabel }, ...(classSummary ? [{ metric: t("exportContent.metricClass"), value: classSummary.className }] : []),
{ metric: "综合得分", value: overallScore }, { metric: t("exportContent.metricPeriod"), value: periodLabel },
{ metric: "报告状态", value: report.status }, { metric: t("exportContent.metricScore"), value: overallScore },
{ metric: "生成人", value: report.generatedByName ?? "-" }, ...(classSummary ? [{ metric: t("exportContent.metricStudentCount"), value: classSummary.studentCount }] : []),
{ metric: "生成时间", value: report.createdAt.split("T")[0] }, ...(classSummary ? [{ metric: t("exportContent.metricAttentionCount"), value: classSummary.studentsNeedingAttention.length }] : []),
{ metric: "摘要", value: summary }, { metric: t("exportContent.metricStatus"), value: report.status },
{ metric: "强项", value: strengths }, { metric: t("exportContent.metricGeneratedBy"), value: report.generatedByName ?? "-" },
{ metric: "弱项", value: weaknesses }, { metric: t("exportContent.metricCreatedAt"), value: report.createdAt.split("T")[0] },
{ metric: "建议", value: recommendations }, { 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({ const sheets: Array<{
sheets: [ name: string
{ columns: Array<{ header: string; key: string; width: number }>
name: "报告概览", rows: Array<Record<string, string | number>>
columns: [ }> = [
{ header: "指标", key: "metric", width: 20 }, {
{ header: "数值", key: "value", width: 60 }, name: t("exportContent.sheetOverview"),
], columns: [
rows: overviewRows, { 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<string> {
const t = await getTranslations("diagnostic.exportContent")
const safePeriod = (period ?? "report").replace(/[\\/:*?"<>|]/g, "_") const safePeriod = (period ?? "report").replace(/[\\/:*?"<>|]/g, "_")
return `诊断报告_${safePeriod}_${formatDateForFile()}.xlsx` const date = formatDateForFile()
return t("filename", { period: safePeriod, date })
} }

View File

@@ -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<DiagnosticRole, DiagnosticRoleConfig> = {
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]
}

View File

@@ -16,6 +16,14 @@ export const GenerateClassReportSchema = z.object({
export type GenerateClassReportInput = z.infer<typeof GenerateClassReportSchema> export type GenerateClassReportInput = z.infer<typeof GenerateClassReportSchema>
/** v4-P2-3: 生成年级诊断报告 */
export const GenerateGradeReportSchema = z.object({
gradeId: z.string().min(1),
period: z.string().min(1),
})
export type GenerateGradeReportInput = z.infer<typeof GenerateGradeReportSchema>
/** 发布诊断报告 */ /** 发布诊断报告 */
export const PublishReportSchema = z.object({ export const PublishReportSchema = z.object({
id: z.string().min(1), id: z.string().min(1),

View File

@@ -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<ActionState<string>> {
const formData = new FormData()
formData.set("studentId", studentId)
formData.set("period", period)
return generateStudentReportAction(null, formData)
},
async generateClassReport(
classId: string,
period: string,
): Promise<ActionState<string>> {
const formData = new FormData()
formData.set("classId", classId)
formData.set("period", period)
return generateClassReportAction(null, formData)
},
async generateGradeReport(
gradeId: string,
period: string,
): Promise<ActionState<string>> {
const formData = new FormData()
formData.set("gradeId", gradeId)
formData.set("period", period)
return generateGradeReportAction(null, formData)
},
async publishReport(id: string): Promise<ActionState<null>> {
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<ActionState<null>> {
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<ActionState<ExportResult>> {
return exportDiagnosticReportAction(reportId)
},
async getClassStudentsByKp(
classId: string,
knowledgePointId: string,
threshold?: number,
): Promise<ActionState<KnowledgePointStudent[]>> {
return getClassStudentsByKnowledgePointAction({
classId,
knowledgePointId,
threshold,
})
},
}

View File

@@ -0,0 +1,58 @@
"use client"
/**
* v2-P2-7: 诊断模块监控埋点 Context。
*
* 通过 React Context 注入 DiagnosticMonitor 实现,
* 使组件可通过 useDiagnosticMonitor() 获取监控实例,
* 而不直接依赖具体埋点 SDK。
*
* 默认值为 noopDiagnosticMonitor不发送任何事件
* 确保未注入 Provider 时业务流程不受影响。
*
* 用法:
* ```tsx
* <DiagnosticMonitorProvider monitor={postHogMonitor}>
* <DiagnosticServiceProvider service={defaultDiagnosticService}>
* <ReportList />
* </DiagnosticServiceProvider>
* </DiagnosticMonitorProvider>
* ```
*/
import { createContext, useContext, type ReactNode } from "react"
import {
noopDiagnosticMonitor,
type DiagnosticMonitor,
} from "./diagnostic-monitor"
const DiagnosticMonitorContext = createContext<DiagnosticMonitor>(
noopDiagnosticMonitor,
)
interface DiagnosticMonitorProviderProps {
/** 监控实现(默认使用 noop生产环境注入真实实现 */
monitor: DiagnosticMonitor
children: ReactNode
}
export function DiagnosticMonitorProvider({
monitor,
children,
}: DiagnosticMonitorProviderProps): ReactNode {
return (
<DiagnosticMonitorContext.Provider value={monitor}>
{children}
</DiagnosticMonitorContext.Provider>
)
}
/**
* 获取当前注入的 DiagnosticMonitor 实例。
*
* 若未注入 Provider返回 no-op 实现,确保调用安全。
*/
export function useDiagnosticMonitor(): DiagnosticMonitor {
return useContext(DiagnosticMonitorContext)
}

View File

@@ -0,0 +1,91 @@
/**
* v2-P2-7: 诊断模块监控埋点接口。
*
* 这是一个预留的扩展点,用于追踪诊断模块的关键操作。
* 默认实现为 no-op不发送任何事件生产环境可通过
* DiagnosticMonitorProvider 注入真实实现(如发送到 Sentry、PostHog、
* Mixpanel、自建埋点系统等
*
* 设计原则:
* - 接口与实现解耦:组件依赖接口,不依赖具体埋点 SDK。
* - 不阻塞主流程:埋点失败不应影响业务操作。
* - 客户端与服务端均可使用:事件类型设计为通用,避免环境耦合。
*
* 用法:
* ```tsx
* <DiagnosticMonitorProvider monitor={postHogDiagnosticMonitor}>
* <DiagnosticServiceProvider service={defaultDiagnosticService}>
* <ReportList />
* </DiagnosticServiceProvider>
* </DiagnosticMonitorProvider>
* ```
*/
/** 诊断模块可追踪的事件名称 */
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
/** 报告 IDpublish/delete/export 事件必填) */
reportId?: string
/** 学生 IDindividual 报告) */
studentId?: string
/** 班级 IDclass 报告或 class_kp_filtered 事件) */
classId?: string
/** 年级 IDgrade 报告) */
gradeId?: string
/** 知识点 IDclass_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<void>
}
/**
* 默认 no-op 实现:不发送任何事件,仅静默返回。
*
* 在未注入真实监控实现时使用,确保业务流程不受影响。
*/
export const noopDiagnosticMonitor: DiagnosticMonitor = {
async track(): Promise<void> {
// no-op: 默认不发送任何事件
},
}

View File

@@ -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<DiagnosticService | null>(null)
interface DiagnosticServiceProviderProps {
service: DiagnosticService
children: ReactNode
}
export function DiagnosticServiceProvider({
service,
children,
}: DiagnosticServiceProviderProps): ReactNode {
return (
<DiagnosticServiceContext.Provider value={service}>
{children}
</DiagnosticServiceContext.Provider>
)
}
/**
* 获取诊断模块服务。
* 必须在 DiagnosticServiceProvider 内部使用。
*/
export function useDiagnosticService(): DiagnosticService {
const service = useContext(DiagnosticServiceContext)
if (!service) {
throw new Error(
"useDiagnosticService must be used within a DiagnosticServiceProvider",
)
}
return service
}

View File

@@ -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<ActionState<string>>
/** 生成班级诊断报告 */
generateClassReport(classId: string, period: string): Promise<ActionState<string>>
/** 生成年级诊断报告 */
generateGradeReport(gradeId: string, period: string): Promise<ActionState<string>>
/** 发布诊断报告 */
publishReport(id: string): Promise<ActionState<null>>
/** 删除诊断报告 */
deleteReport(id: string): Promise<ActionState<null>>
/** 导出诊断报告为 Excel返回 base64 buffer + 文件名) */
exportReport(reportId: string): Promise<ActionState<ExportResult>>
/** 按知识点筛选班级学生掌握度 */
getClassStudentsByKp(
classId: string,
knowledgePointId: string,
threshold?: number,
): Promise<ActionState<KnowledgePointStudent[]>>
}

View File

@@ -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 <T>(
eventName: DiagnosticEventName,
properties: DiagnosticEventProperties,
operation: () => Promise<ActionState<T>>,
): Promise<ActionState<T>> => {
const start = Date.now()
let result: ActionState<T>
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<ActionState<string>> {
return withTracking(
"report_generated",
{ reportType: "individual", studentId, period },
() => service.generateStudentReport(studentId, period),
)
},
async generateClassReport(
classId: string,
period: string,
): Promise<ActionState<string>> {
return withTracking(
"report_generated",
{ reportType: "class", classId, period },
() => service.generateClassReport(classId, period),
)
},
async generateGradeReport(
gradeId: string,
period: string,
): Promise<ActionState<string>> {
return withTracking(
"report_generated",
{ reportType: "grade", gradeId, period },
() => service.generateGradeReport(gradeId, period),
)
},
async publishReport(id: string): Promise<ActionState<null>> {
return withTracking(
"report_published",
{ reportId: id },
() => service.publishReport(id),
)
},
async deleteReport(id: string): Promise<ActionState<null>> {
return withTracking(
"report_deleted",
{ reportId: id },
() => service.deleteReport(id),
)
},
async exportReport(reportId: string): Promise<ActionState<ExportResult>> {
return withTracking(
"report_exported",
{ reportId: reportId },
() => service.exportReport(reportId),
)
},
async getClassStudentsByKp(
classId: string,
knowledgePointId: string,
threshold?: number,
): Promise<ActionState<KnowledgePointStudent[]>> {
return withTracking(
"class_kp_filtered",
{ classId, knowledgePointId, threshold },
() => service.getClassStudentsByKp(classId, knowledgePointId, threshold),
)
},
}
}

View File

@@ -8,6 +8,7 @@
import type { import type {
ClassMasterySummary, ClassMasterySummary,
GradeMasterySummary,
KnowledgePointMastery, KnowledgePointMastery,
KnowledgePointStat, KnowledgePointStat,
MasteryWithKnowledgePoint, 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) * Build student report content (strengths/weaknesses/recommendations/summary)
* from a StudentMasterySummary. * from a StudentMasterySummary.
@@ -280,6 +325,7 @@ export function buildClassMasterySummary(
export function buildStudentReportContent( export function buildStudentReportContent(
summary: StudentMasterySummary, summary: StudentMasterySummary,
period: string, period: string,
translations: ReportContentTranslations,
): { ): {
summaryText: string summaryText: string
strengths: string[] strengths: string[]
@@ -295,14 +341,23 @@ export function buildStudentReportContent(
(m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`, (m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`,
) )
const recommendations = summary.weaknesses.map( const recommendations = summary.weaknesses.map(
(m) => (m) => translations.studentRecommendation({
`建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`, kpName: m.knowledgePointName,
level: m.masteryLevel,
}),
) )
if (recommendations.length === 0) { 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 } return { summaryText, strengths, weaknesses, recommendations, overallScore }
} }
@@ -314,6 +369,7 @@ export function buildStudentReportContent(
export function buildClassReportContent( export function buildClassReportContent(
summary: ClassMasterySummary, summary: ClassMasterySummary,
period: string, period: string,
translations: ReportContentTranslations,
): { ): {
summaryText: string summaryText: string
strengths: string[] strengths: string[]
@@ -334,14 +390,76 @@ export function buildClassReportContent(
(k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`, (k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`,
) )
const recommendations = topWeak.map( const recommendations = topWeak.map(
(k) => (k) => translations.classRecommendation({
`班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`, kpName: k.knowledgePointName,
level: k.averageMastery,
}),
) )
if (recommendations.length === 0) { 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 { return {
summaryText, summaryText,

View File

@@ -39,6 +39,8 @@ export interface DiagnosticReport {
studentId: string | null studentId: string | null
/** v4-P1-4: 班级报告关联的 classId个人报告为 null */ /** v4-P1-4: 班级报告关联的 classId个人报告为 null */
classId: string | null classId: string | null
/** v4-P2-3: 年级报告关联的 gradeId个人/班级报告为 null */
gradeId: string | null
generatedBy: string | null generatedBy: string | null
reportType: DiagnosticReportType reportType: DiagnosticReportType
period: string | null 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 { export interface KnowledgePointStat {
knowledgePointId: string knowledgePointId: string

View File

@@ -23,15 +23,25 @@ import {
openSelection, openSelection,
closeSelection, closeSelection,
} from "./data-access" } 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) => { const revalidateElectivePaths = (id?: string) => {
revalidatePath("/admin/elective") revalidatePath("/admin/elective")
revalidatePath("/teacher/elective") revalidatePath("/teacher/elective")
revalidatePath("/student/elective") revalidatePath("/student/elective")
revalidatePath("/parent/elective")
if (id) { if (id) {
revalidatePath(`/admin/elective/${id}`) revalidatePath(`/admin/elective/${id}`)
revalidatePath(`/admin/elective/${id}/edit`) 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 return id
} }
/**
* 将 ElectiveBusinessError 翻译为用户可见的 i18n 文案。
* 在 catch 中调用,返回 null 表示非业务错误(交给 handleActionError
*/
async function translateBusinessError(
e: unknown,
t: Awaited<ReturnType<typeof getTranslations>>
): Promise<string | null> {
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
}
/** /**
* 校验当前用户对课程的管理权限(资源归属校验)。 * 校验当前用户对课程的管理权限(资源归属校验)。
* - adminscope=all直接放行 * - adminscope=all直接放行
@@ -101,6 +129,9 @@ export async function createElectiveCourseAction(
}) })
return { success: true, message: t("messages.created"), data: id } return { success: true, message: t("messages.created"), data: id }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -153,6 +184,9 @@ export async function updateElectiveCourseAction(
}) })
return { success: true, message: t("messages.updated"), data: id } return { success: true, message: t("messages.updated"), data: id }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -181,6 +215,9 @@ export async function deleteElectiveCourseAction(
}) })
return { success: true, message: t("messages.deleted") } return { success: true, message: t("messages.deleted") }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -209,6 +246,9 @@ export async function openSelectionAction(
}) })
return { success: true, message: t("messages.selectionOpened") } return { success: true, message: t("messages.selectionOpened") }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -237,6 +277,9 @@ export async function closeSelectionAction(
}) })
return { success: true, message: t("messages.selectionClosed") } return { success: true, message: t("messages.selectionClosed") }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -279,6 +322,9 @@ export async function runLotteryAction(
data: result, data: result,
} }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -310,8 +356,13 @@ export async function selectCourseAction(
targetType: "course_selection", targetType: "course_selection",
properties: { status: result.status, priority: parsed.data.priority }, 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) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }
@@ -333,7 +384,7 @@ export async function dropCourseAction(
errors: parsed.error.flatten().fieldErrors, 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) revalidateElectivePaths(parsed.data.courseId)
await trackEvent({ await trackEvent({
event: "elective.course_dropped", event: "elective.course_dropped",
@@ -343,6 +394,9 @@ export async function dropCourseAction(
}) })
return { success: true, message: t("messages.courseDropped") } return { success: true, message: t("messages.courseDropped") }
} catch (e) { } catch (e) {
const t = await getTranslations("elective")
const translated = await translateBusinessError(e, t)
if (translated) return { success: false, message: translated }
return handleActionError(e) return handleActionError(e)
} }
} }

View File

@@ -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 (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button asChild variant="ghost" size="sm">
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
{t("detail.back")}
</Link>
</Button>
{showEditButton && editHref ? (
<Button asChild size="sm">
<Link href={editHref}>
<Pencil className="mr-1 h-4 w-4" />
{t("detail.editCourse")}
</Link>
</Button>
) : null}
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="space-y-1">
<CardTitle className="text-2xl">{course.name}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("description.detail")}
</p>
</div>
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]} className="shrink-0">
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DetailField label={t("fields.subject")} value={course.subjectName} />
<DetailField label={t("fields.grade")} value={course.gradeName} />
<DetailField label={t("fields.teacher")} value={course.teacherName} />
<DetailField
label={t("fields.capacity")}
value={`${course.enrolledCount} / ${course.capacity}`}
/>
<DetailField label={t("fields.classroom")} value={course.classroom} />
<DetailField
label={t("fields.selectionMode")}
value={t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}
/>
<DetailField label={t("fields.credit")} value={course.credit} />
<DetailField label={t("fields.startDate")} value={course.startDate} />
<DetailField label={t("fields.endDate")} value={course.endDate} />
<DetailField label={t("fields.selectionStart")} value={course.selectionStartAt} />
<DetailField label={t("fields.selectionEnd")} value={course.selectionEndAt} />
</div>
{course.schedule ? (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground">{t("fields.schedule")}</p>
<p className="mt-1 text-sm">{course.schedule}</p>
</div>
) : null}
{course.description ? (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground">{t("fields.description")}</p>
<p className="mt-1 text-sm">{course.description}</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Users className="h-5 w-5" />
{t("detail.studentsTitle")}
</CardTitle>
<span className="text-sm text-muted-foreground">
{activeSelections.length}
</span>
</div>
</CardHeader>
<CardContent>
{activeSelections.length === 0 ? (
<EmptyState
title={t("detail.noStudents")}
description={t("detail.noStudentsDescription")}
icon={Users}
className="h-auto border-none shadow-none"
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">#</th>
<th className="pb-2 pr-4 font-medium">{t("detail.studentName")}</th>
<th className="pb-2 pr-4 font-medium">{t("export.statusHeader")}</th>
<th className="pb-2 pr-4 font-medium">{t("detail.priority")}</th>
<th className="pb-2 pr-4 font-medium">{t("detail.selectedAt")}</th>
<th className="pb-2 pr-4 font-medium">{t("detail.enrolledAt")}</th>
</tr>
</thead>
<tbody>
{activeSelections.map((sel, idx) => (
<tr key={sel.id} className="border-b last:border-0">
<td className="py-2 pr-4 text-muted-foreground">{idx + 1}</td>
<td className="py-2 pr-4 font-medium">
{sel.studentName ?? "—"}
</td>
<td className="py-2 pr-4">
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge>
</td>
<td className="py-2 pr-4 tabular-nums">
{sel.priority ?? "—"}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{sel.selectedAt
? new Date(sel.selectedAt).toLocaleDateString()
: "—"}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{sel.enrolledAt
? new Date(sel.enrolledAt).toLocaleDateString()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function DetailField({
label,
value,
}: {
label: string
value: string | null | undefined
}) {
return (
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium">{value ?? "—"}</p>
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
@@ -69,20 +70,21 @@ export function ElectiveCourseForm({
: null : null
if (!res) { if (!res) {
toast.error("Invalid form state") toast.error(t("form.invalidFormState"))
return return
} }
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message)
// 根据 backHref 推断返回列表页路径
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective" const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
router.push(redirectBase) router.push(redirectBase)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to save course") toast.error(res.message || t("form.saveFailed"))
} }
} catch { } catch {
toast.error("Failed to save course") toast.error(t("form.saveFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -92,14 +94,14 @@ export function ElectiveCourseForm({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{mode === "create" ? "New Elective Course" : "Edit Elective Course"} {mode === "create" ? t("form.createTitle") : t("form.editTitle")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form action={handleSubmit} className="space-y-6"> <form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name">Course Name *</Label> <Label htmlFor="name">{t("form.nameLabel")}</Label>
<Input <Input
id="name" id="name"
name="name" name="name"
@@ -109,10 +111,10 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Subject</Label> <Label>{t("form.subjectLabel")}</Label>
<Select value={subjectId} onValueChange={setSubjectId}> <Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a subject" /> <SelectValue placeholder={t("form.selectSubjectPlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{subjects.map((s) => ( {subjects.map((s) => (
@@ -126,10 +128,10 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Grade</Label> <Label>{t("form.gradeLabel")}</Label>
<Select value={gradeId} onValueChange={setGradeId}> <Select value={gradeId} onValueChange={setGradeId}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a grade" /> <SelectValue placeholder={t("form.selectGradePlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{grades.map((g) => ( {grades.map((g) => (
@@ -143,15 +145,15 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Teacher</Label> <Label>{t("form.teacherLabel")}</Label>
<Select value={teacherId} onValueChange={setTeacherId}> <Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a teacher" /> <SelectValue placeholder={t("form.selectTeacherPlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{teachers.map((t) => ( {teachers.map((teacher) => (
<SelectItem key={t.id} value={t.id}> <SelectItem key={teacher.id} value={teacher.id}>
{t.name} {teacher.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -160,7 +162,7 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="capacity">Capacity</Label> <Label htmlFor="capacity">{t("form.capacityLabel")}</Label>
<Input <Input
id="capacity" id="capacity"
name="capacity" name="capacity"
@@ -172,7 +174,7 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="classroom">Classroom</Label> <Label htmlFor="classroom">{t("form.classroomLabel")}</Label>
<Input <Input
id="classroom" id="classroom"
name="classroom" name="classroom"
@@ -181,17 +183,17 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="schedule">Schedule</Label> <Label htmlFor="schedule">{t("form.scheduleLabel")}</Label>
<Input <Input
id="schedule" id="schedule"
name="schedule" name="schedule"
placeholder="e.g. Mon 14:00-15:30" placeholder={t("form.schedulePlaceholder")}
defaultValue={course?.schedule ?? ""} defaultValue={course?.schedule ?? ""}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="credit">Credit</Label> <Label htmlFor="credit">{t("form.creditLabel")}</Label>
<Input <Input
id="credit" id="credit"
name="credit" name="credit"
@@ -222,7 +224,7 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="startDate">Start Date</Label> <Label htmlFor="startDate">{t("form.startDateLabel")}</Label>
<Input <Input
id="startDate" id="startDate"
name="startDate" name="startDate"
@@ -232,7 +234,7 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="endDate">End Date</Label> <Label htmlFor="endDate">{t("form.endDateLabel")}</Label>
<Input <Input
id="endDate" id="endDate"
name="endDate" name="endDate"
@@ -242,7 +244,7 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="selectionStartAt">Selection Start</Label> <Label htmlFor="selectionStartAt">{t("form.selectionStartLabel")}</Label>
<Input <Input
id="selectionStartAt" id="selectionStartAt"
name="selectionStartAt" name="selectionStartAt"
@@ -256,7 +258,7 @@ export function ElectiveCourseForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="selectionEndAt">Selection End</Label> <Label htmlFor="selectionEndAt">{t("form.selectionEndLabel")}</Label>
<Input <Input
id="selectionEndAt" id="selectionEndAt"
name="selectionEndAt" name="selectionEndAt"
@@ -268,14 +270,32 @@ export function ElectiveCourseForm({
} }
/> />
</div> </div>
{/* P2-4退课截止时间 */}
<div className="grid gap-2">
<Label htmlFor="dropDeadline">{t("form.dropDeadlineLabel")}</Label>
<Input
id="dropDeadline"
name="dropDeadline"
type="datetime-local"
defaultValue={
course?.dropDeadline
? new Date(course.dropDeadline).toISOString().slice(0, 16)
: ""
}
/>
<p className="text-xs text-muted-foreground">
{t("form.dropDeadlineHint")}
</p>
</div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t("form.descriptionLabel")}</Label>
<Textarea <Textarea
id="description" id="description"
name="description" name="description"
placeholder="Course description..." placeholder={t("form.descriptionPlaceholder")}
className="min-h-[80px]" className="min-h-[80px]"
defaultValue={course?.description ?? ""} defaultValue={course?.description ?? ""}
/> />
@@ -285,13 +305,12 @@ export function ElectiveCourseForm({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => router.push(backHref ?? "/admin/elective")} asChild
disabled={isWorking}
> >
Cancel <Link href={backHref ?? "/admin/elective"}>{t("form.cancelButton")}</Link>
</Button> </Button>
<Button type="submit" disabled={isWorking}> <Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"} {isWorking ? t("form.savingButton") : mode === "create" ? t("form.createButton") : t("form.saveButton")}
</Button> </Button>
</CardFooter> </CardFooter>
</form> </form>

View File

@@ -2,6 +2,7 @@
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react" import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
@@ -89,10 +90,10 @@ export function ElectiveCourseList({
</p> </p>
{manageResolved && createHref ? ( {manageResolved && createHref ? (
<Button asChild> <Button asChild>
<a href={createHref}> <Link href={createHref}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t("actions.create")} {t("actions.create")}
</a> </Link>
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -174,10 +175,10 @@ export function ElectiveCourseList({
variant="outline" variant="outline"
size="sm" size="sm"
> >
<a href={`${editBaseHref}/${course.id}/edit`}> <Link href={`${editBaseHref}/${course.id}/edit`}>
<Pencil className="mr-1 h-3 w-3" /> <Pencil className="mr-1 h-3 w-3" />
{t("actions.edit")} {t("actions.edit")}
</a> </Link>
</Button> </Button>
) : null} ) : null}
{course.status === "draft" || course.status === "closed" ? ( {course.status === "draft" || course.status === "closed" ? (

View File

@@ -0,0 +1,71 @@
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { ElectiveOverviewStats } from "../data-access-stats"
/**
* 选课模块管理员概览统计卡片网格。
*
* 复用模式:与 attendance-stats-cards 一致的 4 卡片网格。
* 设计原则:
* - 通过 props 注入数据,不直接调用 data-access便于测试与复用
* - 使用 i18n key 解析标题value 由父组件传入已格式化的数据
*/
export function ElectiveStatsCards({ stats }: { stats: ElectiveOverviewStats }) {
const t = useTranslations("elective")
const cards = [
{
title: t("stats.totalCourses"),
value: stats.totalCourses,
icon: BookOpen,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: t("stats.totalEnrolled"),
value: stats.totalEnrolled,
icon: Users,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: t("stats.avgUtilization"),
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
icon: Gauge,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
{
title: t("stats.pendingLottery"),
value: stats.pendingLottery,
icon: Shuffle,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
]
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
role="region"
aria-label={t("stats.totalCourses")}
>
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useTranslations } from "next-intl"
import { BookOpen } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
COURSE_SELECTION_STATUS_BADGE_VARIANTS,
COURSE_SELECTION_STATUS_LABEL_KEYS,
} from "../constants"
import type { CourseSelectionWithDetails } from "../types"
/**
* 家长视角下查看子女选课的只读视图。
*
* 设计原则:
* - 只读:家长不能替子女选/退课,仅展示已选记录
* - 复用:复用 Badge variant 映射 + i18n key与 StudentSelectionView 一致
* - 安全:组件本身不依赖 studentId由父页面注入已过滤的数据
*/
export function ParentSelectionView({
selections,
studentName,
}: {
selections: CourseSelectionWithDetails[]
studentName: string
}) {
const t = useTranslations("elective")
const activeSelections = selections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status)
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold border-b pb-2">{studentName}</h3>
<span className="text-sm text-muted-foreground">
{activeSelections.length}
</span>
</div>
{activeSelections.length === 0 ? (
<EmptyState
title={t("parent.noRecordsTitle")}
description={t("parent.noRecordsDescription")}
icon={BookOpen}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{activeSelections.map((sel) => (
<Card key={sel.id}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="text-base">
{sel.courseName ?? t("errors.notFound")}
</CardTitle>
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
</p>
) : null}
{sel.lotteryRank ? (
<p className="text-xs text-muted-foreground">
#{sel.lotteryRank}
</p>
) : null}
{sel.selectedAt ? (
<p className="text-xs text-muted-foreground">
{t("export.selectedAtHeader")}: {new Date(sel.selectedAt).toLocaleDateString()}
</p>
) : null}
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}

View File

@@ -21,6 +21,8 @@ import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { import {
COURSE_SELECTION_STATUS_BADGE_VARIANTS, COURSE_SELECTION_STATUS_BADGE_VARIANTS,
@@ -35,24 +37,171 @@ import type {
} from "../types" } from "../types"
import { selectCourseAction, dropCourseAction } from "../actions" import { selectCourseAction, dropCourseAction } from "../actions"
export function StudentSelectionView({ /**
availableCourses, * 已选课程区块(我的选课)
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"退课"操作与展示
*/
export function StudentMySelectionsSection({
mySelections, mySelections,
}: { }: {
availableCourses: ElectiveCourseWithDetails[]
mySelections: CourseSelectionWithDetails[] mySelections: CourseSelectionWithDetails[]
}) { }) {
const router = useRouter() const router = useRouter()
const t = useTranslations("elective") const t = useTranslations("elective")
const [pendingId, setPendingId] = useState<string | null>(null) const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
// P2-4退课理由输入按课程 ID 隔离,便于多卡片独立填写)
const [dropReasonMap, setDropReasonMap] = useState<Record<string, string>>({})
const activeSelections = mySelections.filter((s) => const activeSelections = mySelections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status) ["selected", "enrolled", "waitlist"].includes(s.status)
) )
const selectedCourseIds = new Set(
activeSelections.map((s) => s.courseId) const handleDrop = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
// P2-4透传退课理由
const reason = dropReasonMap[courseId]
if (reason && reason.trim().length > 0) {
formData.set("dropReason", reason.trim())
}
const res = await dropCourseAction(null, formData)
if (res.success) {
toast.success(res.message || t("student.dropSuccess"))
router.refresh()
// 清空该课程的退课理由
setDropReasonMap((prev) => {
const next = { ...prev }
delete next[courseId]
return next
})
} else {
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
}
return (
<section className="space-y-4" aria-labelledby="student-my-selections-heading">
<div className="flex items-center justify-between">
<h3
id="student-my-selections-heading"
className="text-lg font-semibold"
>
{t("student.mySelections")}
</h3>
<span className="text-sm text-muted-foreground" aria-live="polite">
{activeSelections.length}
</span>
</div>
{activeSelections.length === 0 ? (
<EmptyState
title={t("list.empty")}
description={t("description.student")}
icon={BookOpen}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{activeSelections.map((sel) => (
<Card key={sel.id}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="text-base">
{sel.courseName ?? t("errors.notFound")}
</CardTitle>
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity}
</p>
) : null}
{sel.lotteryRank ? (
<p className="text-xs text-muted-foreground">
#{sel.lotteryRank}
</p>
) : null}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isPending && pendingId === sel.courseId}
>
<XCircle className="mr-1 h-3 w-3" />
{t("actions.drop")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle>
<AlertDialogDescription>
{t("student.confirmDrop")}
</AlertDialogDescription>
</AlertDialogHeader>
{/* P2-4可选退课理由输入 */}
<div className="grid gap-2 py-2">
<Label htmlFor={`dropReason-${sel.courseId}`}>
{t("fields.dropReason")}
</Label>
<Textarea
id={`dropReason-${sel.courseId}`}
value={dropReasonMap[sel.courseId] ?? ""}
onChange={(e) =>
setDropReasonMap((prev) => ({
...prev,
[sel.courseId]: e.target.value,
}))
}
placeholder={t("student.dropReasonPlaceholder")}
className="min-h-[60px]"
maxLength={255}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDrop(sel.courseId)}
>
{t("actions.drop")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
))}
</div>
)}
</section>
) )
}
/**
* 可选课程区块
* - 独立 Suspense 边界内的客户端组件
* - 仅负责"选课"操作与展示
* - 通过 selectedCourseIds prop 接收已选课程 ID 集合,避免与已选区块直接耦合
*/
export function StudentAvailableCoursesSection({
availableCourses,
selectedCourseIds,
}: {
availableCourses: ElectiveCourseWithDetails[]
selectedCourseIds: Set<string>
}) {
const router = useRouter()
const t = useTranslations("elective")
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const handleSelect = (courseId: string) => { const handleSelect = (courseId: string) => {
setPendingId(courseId) setPendingId(courseId)
@@ -70,179 +219,93 @@ export function StudentSelectionView({
}) })
} }
const handleDrop = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await dropCourseAction(null, formData)
if (res.success) {
toast.success(res.message || t("student.dropSuccess"))
router.refresh()
} else {
toast.error(res.message ?? t("errors.unexpected"))
}
setPendingId(null)
})
}
return ( return (
<div className="space-y-8"> <section className="space-y-4" aria-labelledby="student-available-courses-heading">
<section className="space-y-4"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <h3
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3> id="student-available-courses-heading"
<span className="text-sm text-muted-foreground"> className="text-lg font-semibold"
{activeSelections.length} >
</span> {t("student.availableCourses")}
</div> </h3>
{activeSelections.length === 0 ? ( <span className="text-sm text-muted-foreground" aria-live="polite">
<EmptyState {availableCourses.length}
title={t("list.empty")} </span>
description={t("description.student")} </div>
icon={BookOpen} {availableCourses.length === 0 ? (
className="h-auto border-none shadow-none" <EmptyState
/> title={t("list.emptyStudent")}
) : ( description={t("description.student")}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> icon={BookOpen}
{activeSelections.map((sel) => ( className="h-auto border-none shadow-none"
<Card key={sel.id}> />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{availableCourses.map((course) => {
const isFull = course.enrolledCount >= course.capacity
const alreadySelected = selectedCourseIds.has(course.id)
const isPendingThis = isPending && pendingId === course.id
return (
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0"> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="text-base"> <CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
{sel.courseName ?? t("errors.notFound")} <Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
</CardTitle> {t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
<Badge variant={COURSE_SELECTION_STATUS_BADGE_VARIANTS[sel.status]}>
{t(COURSE_SELECTION_STATUS_LABEL_KEYS[sel.status])}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="flex flex-1 flex-col gap-3">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? ( <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> {course.subjectName ? (
{t("fields.enrolled")}: {sel.courseEnrolledCount}/{sel.courseCapacity} <Badge variant="outline">{course.subjectName}</Badge>
) : null}
<span>{t("fields.credit")}: {course.credit}</span>
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
</div>
{course.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{course.description}
</p> </p>
) : null} ) : null}
{sel.lotteryRank ? ( <div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
<span className="font-medium">{course.teacherName ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
<span className="font-medium">
{course.enrolledCount}/{course.capacity}
{isFull ? ` (${t("student.capacityFull")})` : ""}
</span>
</div>
</div>
{course.schedule ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
#{sel.lotteryRank} <span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
</p> </p>
) : null} ) : null}
<AlertDialog> <div className="mt-auto pt-2">
<AlertDialogTrigger asChild> {alreadySelected ? (
<Button <Button variant="secondary" size="sm" disabled>
variant="outline" <CheckCircle2 className="mr-1 h-3 w-3" />
size="sm" {t("student.selected")}
className="text-destructive hover:text-destructive"
disabled={isPending && pendingId === sel.courseId}
>
<XCircle className="mr-1 h-3 w-3" />
{t("actions.drop")}
</Button> </Button>
</AlertDialogTrigger> ) : (
<AlertDialogContent> <Button
<AlertDialogHeader> size="sm"
<AlertDialogTitle>{t("student.confirmDrop")}</AlertDialogTitle> disabled={isPendingThis}
<AlertDialogDescription> onClick={() => handleSelect(course.id)}
{t("student.confirmDrop")} >
</AlertDialogDescription> {isPendingThis ? t("actions.select") + "..." : t("actions.select")}
</AlertDialogHeader> </Button>
<AlertDialogFooter> )}
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel> </div>
<AlertDialogAction
onClick={() => handleDrop(sel.courseId)}
>
{t("actions.drop")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
))} )
</div> })}
)}
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
<span className="text-sm text-muted-foreground">
{availableCourses.length}
</span>
</div> </div>
{availableCourses.length === 0 ? ( )}
<EmptyState </section>
title={t("list.emptyStudent")}
description={t("description.student")}
icon={BookOpen}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{availableCourses.map((course) => {
const isFull = course.enrolledCount >= course.capacity
const alreadySelected = selectedCourseIds.has(course.id)
const isPendingThis = isPending && pendingId === course.id
return (
<Card key={course.id} className="flex h-full flex-col" role="article" aria-label={course.name}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
<Badge variant={ELECTIVE_STATUS_BADGE_VARIANTS[course.status]}>
{t(ELECTIVE_STATUS_LABEL_KEYS[course.status])}
</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{course.subjectName ? (
<Badge variant="outline">{course.subjectName}</Badge>
) : null}
<span>{t("fields.credit")}: {course.credit}</span>
<span>· {t(SELECTION_MODE_LABEL_KEYS[course.selectionMode])}</span>
</div>
{course.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{course.description}
</p>
) : null}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">{t("fields.teacher")}:</span>{" "}
<span className="font-medium">{course.teacherName ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground">{t("fields.capacity")}:</span>{" "}
<span className="font-medium">
{course.enrolledCount}/{course.capacity}
{isFull ? ` (${t("student.capacityFull")})` : ""}
</span>
</div>
</div>
{course.schedule ? (
<p className="text-xs text-muted-foreground">
<span className="font-medium">{t("fields.schedule")}:</span> {course.schedule}
</p>
) : null}
<div className="mt-auto pt-2">
{alreadySelected ? (
<Button variant="secondary" size="sm" disabled>
<CheckCircle2 className="mr-1 h-3 w-3" />
{t("student.selected")}
</Button>
) : (
<Button
size="sm"
disabled={isPendingThis}
onClick={() => handleSelect(course.id)}
>
{isPendingThis ? t("actions.select") + "..." : t("actions.select")}
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</section>
</div>
) )
} }

View File

@@ -2,17 +2,48 @@ import "server-only"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm" import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { getTranslations } from "next-intl/server"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
courseSelections, courseSelections,
electiveCourses, electiveCourses,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { BusinessError } from "@/shared/lib/action-utils"
import { sendNotification } from "@/modules/notifications"
import { getElectiveCreditLimit, getCapacityNotifyThreshold } from "./data-access-settings"
import { getStudentGradeId } from "./data-access-selections"
import type { CourseSelectionStatus } from "./types" import type { CourseSelectionStatus } from "./types"
/** 学分上限K12 选修课学期学分上限,可按需调整) */ /**
const MAX_CREDIT_PER_TERM = 10 * 选课模块业务错误码(与 i18n key `errors.*` 对应)。
* 由 actions 层根据 code 通过 getTranslations 翻译为用户可见文案。
*/
export type ElectiveErrorCode =
| "courseNotFound"
| "selectionNotOpen"
| "selectionNotStarted"
| "selectionEnded"
| "alreadySelected"
| "scheduleConflict"
| "creditExceeded"
| "noActiveSelection"
| "dropDeadlinePassed"
/**
* 选课模块业务错误(带 i18n code 与参数)。
* actions 层捕获后用 getTranslations(`elective.errors.${code}`) 翻译。
*/
export class ElectiveBusinessError extends BusinessError {
constructor(
public readonly code: ElectiveErrorCode,
public readonly params?: Record<string, string | number>
) {
super(`elective.errors.${code}`, code)
this.name = "ElectiveBusinessError"
}
}
/** /**
* 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。 * 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。
@@ -25,35 +56,76 @@ export function buildLotteryRankCase(ids: string[], startRank: number): SQL {
} }
/** /**
* 解析课程 schedule 字段为可比较的时间段(纯函数,便于测试)。 * 星期字符串归一化映射(纯函数,便于测试)。
* schedule 格式约定:"周一 14:00-15:30" 或 "Mon 14:00-15:30" * 支持中英文全称与缩写,统一映射为 1-7 数字字符串
* 返回 null 表示无法解析(不参与冲突检测)。
*/ */
export function parseSchedule(schedule: string | null): { day: string; start: string; end: string } | null { const DAY_NORMALIZE_MAP: Readonly<Record<string, string>> = Object.freeze({
if (!schedule || schedule.length === 0) return null // 中文
// 匹配 "周X HH:MM-HH:MM" 或 "Day HH:MM-HH:MM" "周一": "1", "周二": "2", "周三": "3", "周四": "4",
const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+(\d{1,2}:\d{2})\s*[-~]\s*(\d{1,2}:\d{2})/i) "周五": "5", "周六": "6", "周日": "7", "周天": "7",
if (!match) return null "星期一": "1", "星期二": "2", "星期三": "3", "星期四": "4",
const [, day, start, end] = match "星期五": "5", "星期六": "6", "星期日": "7", "星期天": "7",
return { day: day ?? "", start: start ?? "", end: end ?? "" } // 英文全称
monday: "1", tuesday: "2", wednesday: "3", thursday: "4",
friday: "5", saturday: "6", sunday: "7",
// 英文缩写
mon: "1", tue: "2", wed: "3", thu: "4",
fri: "5", sat: "6", sun: "7",
})
export function normalizeDay(day: string): string {
return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day
} }
/** /**
* 检测两个时间段是否冲突(纯函数,便于测试)。 * 解析课程 schedule 字段为可比较的时间段数组(纯函数,便于测试)。
* 仅当 day 相同且时间区间重叠时判定为冲突。 *
* 支持多时段(以逗号或分号分隔),例如:
* - "周一 14:00-15:30"
* - "Mon 14:00-15:30, Wed 16:00-17:30"
* - "周一 14:00-15:30周三 16:00-17:30"
*
* 返回空数组表示无法解析(不参与冲突检测)。
*/
export function parseSchedule(
schedule: string | null
): Array<{ day: string; start: string; end: string }> {
if (!schedule || schedule.length === 0) return []
// 按逗号、分号、中文分号拆分多个时段
const segments = schedule.split(/[,;]/).map((s) => s.trim()).filter(Boolean)
const result: Array<{ day: string; start: string; end: string }> = []
// 支持中英文星期与英文全称/缩写
const dayPattern = "周[一二三四五六日天]|星期[一二三四五六日天]|[Mm]on(?:day)?|[Tt]ue(?:sday)?|[Ww]ed(?:nesday)?|[Tt]hu(?:rsday)?|[Ff]ri(?:day)?|[Ss]at(?:urday)?|[Ss]un(?:day)?"
const timePattern = "(\\d{1,2}):(\\d{2})"
const re = new RegExp(
`^(${dayPattern})\\s+${timePattern}\\s*[-~~至到]\\s*${timePattern}$`,
"i"
)
for (const seg of segments) {
const match = seg.match(re)
if (!match) continue
const [, day, startH, startM, endH, endM] = match
if (!day || !startH || !startM || !endH || !endM) continue
result.push({
day,
start: `${startH.padStart(2, "0")}:${startM}`,
end: `${endH.padStart(2, "0")}:${endM}`,
})
}
return result
}
/**
* 检测两组时间段是否存在冲突(纯函数,便于测试)。
* 仅当 day 相同(归一化后)且时间区间重叠时判定为冲突。
*/ */
export function isScheduleConflict( export function isScheduleConflict(
a: { day: string; start: string; end: string }, a: { day: string; start: string; end: string },
b: { day: string; start: string; end: string } b: { day: string; start: string; end: string }
): boolean { ): boolean {
// 归一化星期表示(周一/Mon → 1周二/Tue → 2 ...
const normalizeDay = (d: string): string => {
const dayMap: Record<string, string> = {
"周一": "1", "周二": "2", "周三": "3", "周四": "4", "周五": "5", "周六": "6", "周日": "7", "周天": "7",
"mon": "1", "tue": "2", "wed": "3", "thu": "4", "fri": "5", "sat": "6", "sun": "7",
}
return dayMap[d.toLowerCase()] ?? d
}
if (normalizeDay(a.day) !== normalizeDay(b.day)) return false if (normalizeDay(a.day) !== normalizeDay(b.day)) return false
return a.start < b.end && b.start < a.end return a.start < b.end && b.start < a.end
} }
@@ -72,8 +144,8 @@ async function checkScheduleConflict(
.from(electiveCourses) .from(electiveCourses)
.where(eq(electiveCourses.id, newCourseId)) .where(eq(electiveCourses.id, newCourseId))
.limit(1) .limit(1)
const newSchedule = parseSchedule(newCourse?.schedule ?? null) const newSlots = parseSchedule(newCourse?.schedule ?? null)
if (!newSchedule) return false if (newSlots.length === 0) return false
const existingCourses = await tx const existingCourses = await tx
.select({ .select({
@@ -89,22 +161,32 @@ async function checkScheduleConflict(
) )
for (const row of existingCourses) { for (const row of existingCourses) {
const existing = parseSchedule(row.schedule) const existingSlots = parseSchedule(row.schedule)
if (existing && isScheduleConflict(newSchedule, existing)) { for (const newSlot of newSlots) {
return true for (const existingSlot of existingSlots) {
if (isScheduleConflict(newSlot, existingSlot)) {
return true
}
}
} }
} }
return false return false
} }
/** /**
* 检测学生学分是否超限P2 建议:学分上限校验)。 * 检测学生学分是否超限P2-4 重构:使用 system_settings 配置化上限)。
* 查询学生已选课程的学分总和,加上新课程学分后是否超过上限。 * 查询学生已选课程的学分总和,加上新课程学分后是否超过上限。
*
* 上限来源(按优先级):
* 1. `creditLimit:grade:<studentGradeId>`(按年级配置)
* 2. `creditLimit:default`(全局配置)
* 3. 默认值 10
*/ */
async function checkCreditLimit( async function checkCreditLimit(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0], tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
studentId: string, studentId: string,
newCourseId: string newCourseId: string,
studentGradeId: string | null
): Promise<{ exceeded: boolean; current: number; max: number }> { ): Promise<{ exceeded: boolean; current: number; max: number }> {
const [newCourse] = await tx const [newCourse] = await tx
.select({ credit: electiveCourses.credit }) .select({ credit: electiveCourses.credit })
@@ -128,10 +210,12 @@ async function checkCreditLimit(
const currentCredit = existing.reduce((sum, row) => sum + Number(row.credit ?? 0), 0) const currentCredit = existing.reduce((sum, row) => sum + Number(row.credit ?? 0), 0)
const total = currentCredit + newCredit const total = currentCredit + newCredit
// 配置化上限:按年级或全局,默认 10
const max = await getElectiveCreditLimit(studentGradeId)
return { return {
exceeded: total > MAX_CREDIT_PER_TERM, exceeded: total > max,
current: total, current: total,
max: MAX_CREDIT_PER_TERM, max,
} }
} }
@@ -157,7 +241,7 @@ export async function runLottery(courseId: string): Promise<{
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)), .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)),
]) ])
const course = courseRows[0] const course = courseRows[0]
if (!course) throw new Error("Course not found") if (!course) throw new ElectiveBusinessError("courseNotFound")
if (selections.length === 0) { if (selections.length === 0) {
return { enrolled: 0, waitlist: 0 } return { enrolled: 0, waitlist: 0 }
@@ -185,6 +269,8 @@ export async function runLottery(courseId: string): Promise<{
const enrolledCount = enrolledIds.length const enrolledCount = enrolledIds.length
const waitlistCount = waitlistIds.length const waitlistCount = waitlistIds.length
// P1-12 改进:抽签后不强制 close 课程,保留 status="open" 以便管理员重抽。
// 管理员可手动通过 closeSelection 关闭选课。
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
if (enrolledIds.length > 0) { if (enrolledIds.length > 0) {
await tx await tx
@@ -207,9 +293,10 @@ export async function runLottery(courseId: string): Promise<{
}) })
.where(inArray(courseSelections.id, waitlistIds)) .where(inArray(courseSelections.id, waitlistIds))
} }
// 仅更新 enrolledCount不自动关闭课程
await tx await tx
.update(electiveCourses) .update(electiveCourses)
.set({ enrolledCount, status: "closed", updatedAt: now }) .set({ enrolledCount, updatedAt: now })
.where(eq(electiveCourses.id, courseId)) .where(eq(electiveCourses.id, courseId))
}) })
@@ -220,7 +307,10 @@ export async function selectCourse(
courseId: string, courseId: string,
studentId: string, studentId: string,
priority?: number priority?: number
): Promise<{ status: CourseSelectionStatus; message: string }> { ): Promise<{ status: CourseSelectionStatus }> {
// P2-4先查询学生年级 ID用于按年级配置的学分上限
const studentGradeId = await getStudentGradeId(studentId)
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
// 锁定课程行,防止 FCFS 模式下并发超卖 // 锁定课程行,防止 FCFS 模式下并发超卖
const [course] = await tx const [course] = await tx
@@ -229,15 +319,15 @@ export async function selectCourse(
.where(eq(electiveCourses.id, courseId)) .where(eq(electiveCourses.id, courseId))
.for("update") .for("update")
.limit(1) .limit(1)
if (!course) throw new Error("Course not found") if (!course) throw new ElectiveBusinessError("courseNotFound")
if (course.status !== "open") throw new Error("Course selection is not open") if (course.status !== "open") throw new ElectiveBusinessError("selectionNotOpen")
const now = new Date() const now = new Date()
if (course.selectionStartAt && now < course.selectionStartAt) { if (course.selectionStartAt && now < course.selectionStartAt) {
throw new Error("Selection has not started yet") throw new ElectiveBusinessError("selectionNotStarted")
} }
if (course.selectionEndAt && now > course.selectionEndAt) { if (course.selectionEndAt && now > course.selectionEndAt) {
throw new Error("Selection has ended") throw new ElectiveBusinessError("selectionEnded")
} }
const [existing] = await tx const [existing] = await tx
@@ -251,18 +341,21 @@ export async function selectCourse(
) )
) )
.limit(1) .limit(1)
if (existing) throw new Error("Already selected this course") if (existing) throw new ElectiveBusinessError("alreadySelected")
// P2 建议:选课时间冲突检测 // P2 建议:选课时间冲突检测
const hasConflict = await checkScheduleConflict(tx, studentId, courseId) const hasConflict = await checkScheduleConflict(tx, studentId, courseId)
if (hasConflict) { if (hasConflict) {
throw new Error("Schedule conflicts with your existing courses") throw new ElectiveBusinessError("scheduleConflict")
} }
// P2 建议:学分上限校验 // P2-4:学分上限校验(使用按年级配置的上限)
const creditCheck = await checkCreditLimit(tx, studentId, courseId) const creditCheck = await checkCreditLimit(tx, studentId, courseId, studentGradeId)
if (creditCheck.exceeded) { if (creditCheck.exceeded) {
throw new Error(`Credit limit exceeded (${creditCheck.current}/${creditCheck.max})`) throw new ElectiveBusinessError("creditExceeded", {
current: creditCheck.current,
max: creditCheck.max,
})
} }
const id = createId() const id = createId()
@@ -272,13 +365,18 @@ export async function selectCourse(
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) { if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
status = "enrolled" status = "enrolled"
enrolledAt = now enrolledAt = now
const newEnrolledCount = course.enrolledCount + 1
await tx await tx
.update(electiveCourses) .update(electiveCourses)
.set({ .set({
enrolledCount: course.enrolledCount + 1, enrolledCount: newEnrolledCount,
updatedAt: now, updatedAt: now,
}) })
.where(eq(electiveCourses.id, courseId)) .where(eq(electiveCourses.id, courseId))
// P2-4容量阈值通知fire-and-forget不阻塞事务
// 仅在跨过阈值时触发(避免每次选课都通知)
void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)
} else if (course.selectionMode === "fcfs") { } else if (course.selectionMode === "fcfs") {
status = "waitlist" status = "waitlist"
} }
@@ -293,21 +391,64 @@ export async function selectCourse(
enrolledAt, enrolledAt,
}) })
return { return { status }
status,
message:
status === "enrolled"
? "Enrolled successfully"
: status === "waitlist"
? "Added to waitlist"
: "Selection submitted",
}
}) })
} }
/**
* 容量阈值通知P2-4 新增)。
*
* 触发条件FCFS 模式下,录取后 `enrolledCount >= capacity * threshold`。
* 阈值来自 system_settings`capacityNotifyThreshold`,默认 0.9)。
*
* 通知接收者:课程创建者/教师teacherId
* 通知为 fire-and-forget失败不影响选课流程。
*
* 防重复策略:仅当 `enrolledCount === Math.ceil(capacity * threshold)` 时触发,
* 即只在跨过阈值的瞬间触发一次。
*/
async function notifyCapacityThresholdIfNeeded(
course: { teacherId: string; id: string; name: string; capacity: number },
newEnrolledCount: number
): Promise<void> {
try {
const threshold = await getCapacityNotifyThreshold()
const triggerPoint = Math.ceil(course.capacity * threshold)
// 仅在跨过阈值瞬间触发(避免每次选课都通知)
if (newEnrolledCount !== triggerPoint) return
// P2-4通知文案使用 i18n 翻译键
const t = await getTranslations("elective")
const title = t("notifications.capacityWarningTitle", { courseName: course.name })
const content = t("notifications.capacityWarningContent", {
courseName: course.name,
enrolled: newEnrolledCount,
capacity: course.capacity,
percent: Math.round(threshold * 100),
})
await sendNotification({
userId: course.teacherId,
title,
content,
type: "warning",
actionUrl: `/admin/elective/${course.id}`,
metadata: {
courseId: course.id,
enrolledCount: newEnrolledCount,
capacity: course.capacity,
threshold,
},
})
} catch {
// fire-and-forget通知失败不影响选课事务
}
}
export async function dropCourse( export async function dropCourse(
courseId: string, courseId: string,
studentId: string studentId: string,
dropReason?: string
): Promise<void> { ): Promise<void> {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [existing] = await tx const [existing] = await tx
@@ -321,7 +462,7 @@ export async function dropCourse(
) )
) )
.limit(1) .limit(1)
if (!existing) throw new Error("No active selection found") if (!existing) throw new ElectiveBusinessError("noActiveSelection")
// 锁定课程行,确保 enrolledCount 更新与候补递补的原子性 // 锁定课程行,确保 enrolledCount 更新与候补递补的原子性
const [course] = await tx const [course] = await tx
@@ -332,9 +473,20 @@ export async function dropCourse(
.limit(1) .limit(1)
const now = new Date() const now = new Date()
// P2-4退课截止时间校验
if (course?.dropDeadline && now > course.dropDeadline) {
throw new ElectiveBusinessError("dropDeadlinePassed")
}
await tx await tx
.update(courseSelections) .update(courseSelections)
.set({ status: "dropped", droppedAt: now, updatedAt: now }) .set({
status: "dropped",
droppedAt: now,
// P2-4记录退课理由trim 后存入,空字符串转为 null
dropReason: dropReason && dropReason.trim().length > 0 ? dropReason.trim() : null,
updatedAt: now,
})
.where(eq(courseSelections.id, existing.id)) .where(eq(courseSelections.id, existing.id))
if (existing.status === "enrolled" && course && course.selectionMode === "fcfs") { if (existing.status === "enrolled" && course && course.selectionMode === "fcfs") {

View File

@@ -37,6 +37,7 @@ type SelectionCoreRow = {
courseCapacity: number | null courseCapacity: number | null
courseEnrolledCount: number | null courseEnrolledCount: number | null
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
dropReason: string | null
} }
const toIso = (d: Date | null | undefined): string | null => const toIso = (d: Date | null | undefined): string | null =>
@@ -56,6 +57,7 @@ const mapSelectionRow = (
selectedAt: toIsoRequired(r.selectedAt), selectedAt: toIsoRequired(r.selectedAt),
enrolledAt: toIso(r.enrolledAt), enrolledAt: toIso(r.enrolledAt),
droppedAt: toIso(r.droppedAt), droppedAt: toIso(r.droppedAt),
dropReason: r.dropReason,
lotteryRank: r.lotteryRank, lotteryRank: r.lotteryRank,
createdAt: toIsoRequired(r.createdAt), createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt), updatedAt: toIsoRequired(r.updatedAt),
@@ -77,6 +79,7 @@ const buildSelectionCoreSelect = () =>
selectedAt: courseSelections.selectedAt, selectedAt: courseSelections.selectedAt,
enrolledAt: courseSelections.enrolledAt, enrolledAt: courseSelections.enrolledAt,
droppedAt: courseSelections.droppedAt, droppedAt: courseSelections.droppedAt,
dropReason: courseSelections.dropReason,
lotteryRank: courseSelections.lotteryRank, lotteryRank: courseSelections.lotteryRank,
createdAt: courseSelections.createdAt, createdAt: courseSelections.createdAt,
updatedAt: courseSelections.updatedAt, updatedAt: courseSelections.updatedAt,

View File

@@ -0,0 +1,97 @@
import "server-only"
import { cache } from "react"
import { eq, and } from "drizzle-orm"
import { db } from "@/shared/db"
import { systemSettings } from "@/shared/db/schema"
/**
* 选课模块配置化设置P2-4 新增)。
*
* 设计原则:
* - 复用全局 `system_settings` 表category="elective"),避免新增独立表
* - 支持按年级覆盖key=`creditLimit:grade:<gradeId>`fallback 到全局key=`creditLimit:default`
* - 用 React `cache()` 包装,单次请求内去重
* - 配置缺失时使用默认值(向后兼容)
*
* 配置项:
* - `creditLimit:default` / `creditLimit:grade:<gradeId>`:学期学分上限(默认 10
* - `capacityNotifyThreshold`:容量阈值通知比例 0-1默认 0.9
*/
const SETTINGS_CATEGORY = "elective"
/** 默认学期学分上限K12 选修课) */
const DEFAULT_MAX_CREDIT_PER_TERM = 10
/** 默认容量阈值通知比例90% */
const DEFAULT_CAPACITY_NOTIFY_THRESHOLD = 0.9
/**
* 读取 system_settings 中指定 key 的值。
* 失败或未配置时返回 null不抛错保证向后兼容
*/
async function readSettingValue(key: string): Promise<string | null> {
const [row] = await db
.select({ value: systemSettings.value, valueType: systemSettings.valueType })
.from(systemSettings)
.where(
and(
eq(systemSettings.category, SETTINGS_CATEGORY),
eq(systemSettings.key, key)
)
)
.limit(1)
return row?.value ?? null
}
/**
* 获取学期学分上限P2-4 新增)。
*
* 查询顺序:
* 1. 若传入 gradeId先查 `creditLimit:grade:<gradeId>`
* 2. 若未配置或未传入 gradeIdfallback 到 `creditLimit:default`
* 3. 都未配置则返回默认值 10
*
* @param gradeId 学生所在年级 ID可选
*/
export const getElectiveCreditLimit = cache(
async (gradeId?: string | null): Promise<number> => {
if (gradeId) {
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
if (gradeValue !== null) {
const parsed = Number(gradeValue)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
}
const defaultValue = await readSettingValue("creditLimit:default")
if (defaultValue !== null) {
const parsed = Number(defaultValue)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
return DEFAULT_MAX_CREDIT_PER_TERM
}
)
/**
* 获取容量阈值通知比例P2-4 新增)。
*
* 当课程 `enrolledCount >= capacity * threshold` 时触发管理员通知。
* 默认 0.990%)。
*/
export const getCapacityNotifyThreshold = cache(
async (): Promise<number> => {
const value = await readSettingValue("capacityNotifyThreshold")
if (value !== null) {
const parsed = Number(value)
if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 1) return parsed
}
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
}
)
/** 导出默认值常量(供测试与文档引用) */
export const ELECTIVE_DEFAULTS = {
MAX_CREDIT_PER_TERM: DEFAULT_MAX_CREDIT_PER_TERM,
CAPACITY_NOTIFY_THRESHOLD: DEFAULT_CAPACITY_NOTIFY_THRESHOLD,
} as const

View File

@@ -0,0 +1,88 @@
import "server-only"
import { cache } from "react"
import { count, eq, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { courseSelections, electiveCourses } from "@/shared/db/schema"
/**
* 选课模块管理员概览统计P1-13 新增)。
* 用于 admin/teacher 列表页顶部展示关键指标。
*/
export interface ElectiveOverviewStats {
/** 课程总数 */
totalCourses: number
/** 总选课人数(已录取 + 候补 + 已选) */
totalEnrolled: number
/** 平均容量使用率百分比0-100 */
avgUtilization: number
/** 待抽签的课程数selectionMode=lottery 且 status=open 且存在 selected 记录) */
pendingLottery: number
}
/**
* 获取选修课全局概览统计admin 视角)。
*
* 实现要点:
* - 4 个独立查询合并为 3 个 SQLpendingLottery 需 join避免 N+1
* - 使用 SQL 聚合而非拉全表后 reduce避免大数据量内存峰值
* - admin 不做 scope 过滤(统计全部课程)
*/
export const getElectiveOverviewStats = cache(
async (): Promise<ElectiveOverviewStats> => {
// 并行执行聚合查询
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
// 1. 课程总数
db
.select({ total: count() })
.from(electiveCourses),
// 2. 总选课人数(活跃选课记录数)
db
.select({ total: count() })
.from(courseSelections)
.where(
sql`${courseSelections.status} IN ('selected', 'enrolled', 'waitlist')`
),
// 3. 平均容量使用率capacity > 0 时计算 enrolledCount/capacity 平均值)
db
.select({
avg: sql<number>`COALESCE(
AVG(
CASE
WHEN ${electiveCourses.capacity} > 0
THEN ${electiveCourses.enrolledCount}::float / ${electiveCourses.capacity}
ELSE 0
END
) * 100,
0
)`,
})
.from(electiveCourses),
// 4. 待抽签课程数lottery 模式且 status=open 且有 selected 状态的选课记录
db
.select({ total: sql<number>`count(distinct ${electiveCourses.id})` })
.from(electiveCourses)
.innerJoin(
courseSelections,
eq(courseSelections.courseId, electiveCourses.id)
)
.where(
sql`${electiveCourses.selectionMode} = 'lottery'
AND ${electiveCourses.status} = 'open'
AND ${courseSelections.status} = 'selected'`
),
])
return {
totalCourses: totalRow[0]?.total ?? 0,
totalEnrolled: enrolledRow[0]?.total ?? 0,
avgUtilization: Math.round(Number(utilizationRow[0]?.avg ?? 0)),
pendingLottery: pendingRow[0]?.total ?? 0,
}
}
)

View File

@@ -62,6 +62,7 @@ export const mapCourseRow = (
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null, endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
selectionStartAt: toIso(r.selectionStartAt), selectionStartAt: toIso(r.selectionStartAt),
selectionEndAt: toIso(r.selectionEndAt), selectionEndAt: toIso(r.selectionEndAt),
dropDeadline: toIso(r.dropDeadline),
status: r.status, status: r.status,
selectionMode: r.selectionMode, selectionMode: r.selectionMode,
credit: String(r.credit), credit: String(r.credit),
@@ -89,6 +90,7 @@ export const buildCourseSelect = () =>
endDate: electiveCourses.endDate, endDate: electiveCourses.endDate,
selectionStartAt: electiveCourses.selectionStartAt, selectionStartAt: electiveCourses.selectionStartAt,
selectionEndAt: electiveCourses.selectionEndAt, selectionEndAt: electiveCourses.selectionEndAt,
dropDeadline: electiveCourses.dropDeadline,
status: electiveCourses.status, status: electiveCourses.status,
selectionMode: electiveCourses.selectionMode, selectionMode: electiveCourses.selectionMode,
credit: electiveCourses.credit, credit: electiveCourses.credit,
@@ -133,51 +135,41 @@ export const getElectiveCourses = cache(
async ( async (
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string } params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
): Promise<ElectiveCourseWithDetails[]> => { ): Promise<ElectiveCourseWithDetails[]> => {
try { const conditions: SQL[] = []
const conditions: SQL[] = [] if (params?.status)
if (params?.status) conditions.push(
conditions.push( eq(electiveCourses.status, params.status)
eq(electiveCourses.status, params.status) )
) if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId)) if (params?.subjectId)
if (params?.subjectId) conditions.push(eq(electiveCourses.subjectId, params.subjectId))
conditions.push(eq(electiveCourses.subjectId, params.subjectId)) if (params?.teacherId)
if (params?.teacherId) conditions.push(eq(electiveCourses.teacherId, params.teacherId))
conditions.push(eq(electiveCourses.teacherId, params.teacherId)) if (params?.scope) {
if (params?.scope) { const scopeFilter = buildScopeFilter(params.scope, params.currentUserId)
const scopeFilter = buildScopeFilter(params.scope, params.currentUserId) if (scopeFilter) conditions.push(scopeFilter)
if (scopeFilter) conditions.push(scopeFilter)
}
const query = buildCourseSelect()
const rows = await (conditions.length > 0
? query.where(and(...conditions))
: query
).orderBy(desc(electiveCourses.createdAt))
if (rows.length === 0) return []
const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
} catch (error) {
console.error("getElectiveCourses failed:", error)
return []
} }
const query = buildCourseSelect()
const rows = await (conditions.length > 0
? query.where(and(...conditions))
: query
).orderBy(desc(electiveCourses.createdAt))
if (rows.length === 0) return []
const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
} }
) )
export const getElectiveCourseById = cache( export const getElectiveCourseById = cache(
async (id: string): Promise<ElectiveCourseWithDetails | null> => { async (id: string): Promise<ElectiveCourseWithDetails | null> => {
try { const [row] = await buildCourseSelect()
const [row] = await buildCourseSelect() .where(eq(electiveCourses.id, id))
.where(eq(electiveCourses.id, id)) .limit(1)
.limit(1) if (!row) return null
if (!row) return null const displayMaps = await resolveCourseDisplayNames([row])
const displayMaps = await resolveCourseDisplayNames([row]) return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
} catch (error) {
console.error("getElectiveCourseById failed:", error)
return null
}
} }
) )
@@ -201,6 +193,8 @@ export async function createElectiveCourse(
endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null, endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null,
selectionStartAt: data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null, selectionStartAt: data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null,
selectionEndAt: data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null, selectionEndAt: data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null,
// P2-4退课截止时间
dropDeadline: data.dropDeadline ? safeParseDate(data.dropDeadline, "退课截止时间") : null,
status: "draft", status: "draft",
selectionMode: data.selectionMode, selectionMode: data.selectionMode,
credit: data.credit, credit: data.credit,
@@ -229,6 +223,9 @@ export async function updateElectiveCourse(
update.selectionStartAt = data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null update.selectionStartAt = data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null
if (data.selectionEndAt !== undefined) if (data.selectionEndAt !== undefined)
update.selectionEndAt = data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null update.selectionEndAt = data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null
// P2-4退课截止时间
if (data.dropDeadline !== undefined)
update.dropDeadline = data.dropDeadline ? safeParseDate(data.dropDeadline, "退课截止时间") : null
if (data.status !== undefined) update.status = data.status if (data.status !== undefined) update.status = data.status
if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode
if (data.credit !== undefined) update.credit = data.credit if (data.credit !== undefined) update.credit = data.credit

View File

@@ -6,10 +6,15 @@ import { exportToExcel } from "@/shared/lib/excel"
import { getElectiveCourses } from "./data-access" import { getElectiveCourses } from "./data-access"
import { getCourseSelections } from "./data-access-selections" import { getCourseSelections } from "./data-access-selections"
import {
ElectiveCourseStatusEnum,
} from "./schema"
/** /**
* 导出选修课课程列表到 Excel * 导出选修课课程列表到 Excel
* Sheet 1: 课程明细 * Sheet 1: 课程明细
*
* 注意:通过 Zod 枚举做类型守卫,避免 `as` 类型断言。
*/ */
export async function exportElectiveCoursesToExcel(params: { export async function exportElectiveCoursesToExcel(params: {
status?: string status?: string
@@ -17,8 +22,14 @@ export async function exportElectiveCoursesToExcel(params: {
}): Promise<Buffer> { }): Promise<Buffer> {
const t = await getTranslations("elective") const t = await getTranslations("elective")
// 用 Zod 枚举校验 status通过则类型收窄为 ElectiveCourseStatus
const statusParsed = params.status
? ElectiveCourseStatusEnum.safeParse(params.status)
: undefined
const status = statusParsed?.success ? statusParsed.data : undefined
const courses = await getElectiveCourses({ const courses = await getElectiveCourses({
status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined, status,
teacherId: params.teacherId, teacherId: params.teacherId,
}) })
@@ -33,7 +44,7 @@ export async function exportElectiveCoursesToExcel(params: {
[t("fields.schedule")]: c.schedule ?? "", [t("fields.schedule")]: c.schedule ?? "",
[t("fields.credit")]: c.credit, [t("fields.credit")]: c.credit,
[t("fields.selectionMode")]: t(`selectionMode.${c.selectionMode}`), [t("fields.selectionMode")]: t(`selectionMode.${c.selectionMode}`),
status: t(`status.${c.status}`), [t("export.statusHeader")]: t(`status.${c.status}`),
[t("fields.startDate")]: c.startDate ?? "", [t("fields.startDate")]: c.startDate ?? "",
[t("fields.endDate")]: c.endDate ?? "", [t("fields.endDate")]: c.endDate ?? "",
})) }))
@@ -41,7 +52,7 @@ export async function exportElectiveCoursesToExcel(params: {
return exportToExcel({ return exportToExcel({
sheets: [ sheets: [
{ {
name: t("title.adminList"), name: t("export.courseSheetName"),
columns: [ columns: [
{ header: t("fields.name"), key: t("fields.name"), width: 24 }, { header: t("fields.name"), key: t("fields.name"), width: 24 },
{ header: t("fields.teacher"), key: t("fields.teacher"), width: 16 }, { header: t("fields.teacher"), key: t("fields.teacher"), width: 16 },
@@ -53,7 +64,7 @@ export async function exportElectiveCoursesToExcel(params: {
{ header: t("fields.schedule"), key: t("fields.schedule"), width: 20 }, { header: t("fields.schedule"), key: t("fields.schedule"), width: 20 },
{ header: t("fields.credit"), key: t("fields.credit"), width: 8 }, { header: t("fields.credit"), key: t("fields.credit"), width: 8 },
{ header: t("fields.selectionMode"), key: t("fields.selectionMode"), width: 16 }, { header: t("fields.selectionMode"), key: t("fields.selectionMode"), width: 16 },
{ header: "Status", key: "status", width: 12 }, { header: t("export.statusHeader"), key: t("export.statusHeader"), width: 12 },
{ header: t("fields.startDate"), key: t("fields.startDate"), width: 14 }, { header: t("fields.startDate"), key: t("fields.startDate"), width: 14 },
{ header: t("fields.endDate"), key: t("fields.endDate"), width: 14 }, { header: t("fields.endDate"), key: t("fields.endDate"), width: 14 },
], ],
@@ -75,25 +86,25 @@ export async function exportCourseSelectionsToExcel(params: {
const selections = await getCourseSelections(params.courseId) const selections = await getCourseSelections(params.courseId)
const rows = selections.map((s, idx) => ({ const rows = selections.map((s, idx) => ({
"#": idx + 1, [t("export.indexHeader")]: idx + 1,
[t("fields.name")]: s.studentName ?? "", [t("fields.name")]: s.studentName ?? "",
status: t(`selectionStatus.${s.status}`), [t("export.statusHeader")]: t(`selectionStatus.${s.status}`),
priority: s.priority ?? 1, [t("export.priorityHeader")]: s.priority ?? 1,
selectedAt: s.selectedAt.split("T")[0], [t("export.selectedAtHeader")]: s.selectedAt.split("T")[0],
enrolledAt: s.enrolledAt ? s.enrolledAt.split("T")[0] : "", [t("export.enrolledAtHeader")]: s.enrolledAt ? s.enrolledAt.split("T")[0] : "",
})) }))
return exportToExcel({ return exportToExcel({
sheets: [ sheets: [
{ {
name: t("student.mySelections"), name: t("export.selectionSheetName"),
columns: [ columns: [
{ header: "#", key: "#", width: 6 }, { header: t("export.indexHeader"), key: t("export.indexHeader"), width: 6 },
{ header: t("fields.name"), key: t("fields.name"), width: 18 }, { header: t("fields.name"), key: t("fields.name"), width: 18 },
{ header: "Status", key: "status", width: 12 }, { header: t("export.statusHeader"), key: t("export.statusHeader"), width: 12 },
{ header: "Priority", key: "priority", width: 10 }, { header: t("export.priorityHeader"), key: t("export.priorityHeader"), width: 10 },
{ header: "Selected At", key: "selectedAt", width: 14 }, { header: t("export.selectedAtHeader"), key: t("export.selectedAtHeader"), width: 14 },
{ header: "Enrolled At", key: "enrolledAt", width: 14 }, { header: t("export.enrolledAtHeader"), key: t("export.enrolledAtHeader"), width: 14 },
], ],
rows, rows,
}, },

View File

@@ -63,6 +63,13 @@ export const CreateElectiveCourseSchema = z
.optional() .optional()
.nullable() .nullable()
.refine(isValidDateString, "选课结束时间格式无效"), .refine(isValidDateString, "选课结束时间格式无效"),
/** 退课截止时间P2-4 新增):超过此时间学生不可退课 */
dropDeadline: z
.string()
.trim()
.optional()
.nullable()
.refine(isValidDateString, "退课截止时间格式无效"),
selectionMode: ElectiveSelectionModeEnum.optional(), selectionMode: ElectiveSelectionModeEnum.optional(),
credit: z.string().trim().optional().nullable(), credit: z.string().trim().optional().nullable(),
}) })
@@ -79,6 +86,7 @@ export const CreateElectiveCourseSchema = z
endDate: optionalStringToNull(v.endDate), endDate: optionalStringToNull(v.endDate),
selectionStartAt: optionalStringToNull(v.selectionStartAt), selectionStartAt: optionalStringToNull(v.selectionStartAt),
selectionEndAt: optionalStringToNull(v.selectionEndAt), selectionEndAt: optionalStringToNull(v.selectionEndAt),
dropDeadline: optionalStringToNull(v.dropDeadline),
selectionMode: v.selectionMode ?? "fcfs", selectionMode: v.selectionMode ?? "fcfs",
credit: v.credit && v.credit.length > 0 ? v.credit : "1.0", credit: v.credit && v.credit.length > 0 ? v.credit : "1.0",
})) }))
@@ -119,6 +127,13 @@ export const UpdateElectiveCourseSchema = z
.optional() .optional()
.nullable() .nullable()
.refine(isValidDateString, "选课结束时间格式无效"), .refine(isValidDateString, "选课结束时间格式无效"),
/** 退课截止时间P2-4 新增):超过此时间学生不可退课 */
dropDeadline: z
.string()
.trim()
.optional()
.nullable()
.refine(isValidDateString, "退课截止时间格式无效"),
status: ElectiveCourseStatusEnum.optional(), status: ElectiveCourseStatusEnum.optional(),
selectionMode: ElectiveSelectionModeEnum.optional(), selectionMode: ElectiveSelectionModeEnum.optional(),
credit: z.string().trim().optional().nullable(), credit: z.string().trim().optional().nullable(),
@@ -149,6 +164,10 @@ export const UpdateElectiveCourseSchema = z
v.selectionEndAt !== undefined v.selectionEndAt !== undefined
? optionalStringToNull(v.selectionEndAt) ? optionalStringToNull(v.selectionEndAt)
: undefined, : undefined,
dropDeadline:
v.dropDeadline !== undefined
? optionalStringToNull(v.dropDeadline)
: undefined,
credit: credit:
v.credit !== undefined v.credit !== undefined
? v.credit && v.credit.length > 0 ? v.credit && v.credit.length > 0
@@ -168,6 +187,8 @@ export type SelectCourseInput = z.infer<typeof SelectCourseSchema>
export const DropCourseSchema = z.object({ export const DropCourseSchema = z.object({
courseId: z.string().trim().min(1), courseId: z.string().trim().min(1),
/** 退课理由P2-4 新增):可选,最长 255 字符 */
dropReason: z.string().trim().max(255).optional(),
}) })
export type DropCourseInput = z.infer<typeof DropCourseSchema> export type DropCourseInput = z.infer<typeof DropCourseSchema>

View File

@@ -24,6 +24,8 @@ export interface ElectiveCourse {
endDate: string | null endDate: string | null
selectionStartAt: string | null selectionStartAt: string | null
selectionEndAt: string | null selectionEndAt: string | null
/** 退课截止时间P2-4 新增ISO 字符串,为 null 表示不限制 */
dropDeadline: string | null
status: ElectiveCourseStatus status: ElectiveCourseStatus
selectionMode: ElectiveSelectionMode selectionMode: ElectiveSelectionMode
credit: string credit: string
@@ -46,6 +48,8 @@ export interface CourseSelection {
selectedAt: string selectedAt: string
enrolledAt: string | null enrolledAt: string | null
droppedAt: string | null droppedAt: string | null
/** 退课理由P2-4 新增):学生退课时可选填写 */
dropReason: string | null
lotteryRank: number | null lotteryRank: number | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string