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:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/modules/dashboard/components/comparison-badge.tsx
Normal file
82
src/modules/dashboard/components/comparison-badge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Bell, ChevronRight } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘通知中心 Widget(L5)。
|
||||||
|
*
|
||||||
|
* 集成到仪表盘侧边栏,显示最近通知摘要。
|
||||||
|
* 完整通知下拉由 SiteHeader 的 NotificationDropdown 处理,
|
||||||
|
* 此 Widget 提供仪表盘内的快速入口。
|
||||||
|
*/
|
||||||
|
export interface DashboardNotificationItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
createdAt: string
|
||||||
|
read: boolean
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardNotificationWidget({
|
||||||
|
notifications,
|
||||||
|
viewAllHref = "/notifications",
|
||||||
|
}: {
|
||||||
|
notifications: readonly DashboardNotificationItem[]
|
||||||
|
viewAllHref?: string
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
|
const unreadCount = notifications.filter((n) => !n.read).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 类。
|
||||||
|
*
|
||||||
|
* V4(P3-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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 变体优先级映射(数值越小优先级越高),V4(P3-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
|
||||||
|
|||||||
67
src/modules/dashboard/config/widget-configs.ts
Normal file
67
src/modules/dashboard/config/widget-configs.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/modules/dashboard/hooks/use-dashboard-preferences.ts
Normal file
82
src/modules/dashboard/hooks/use-dashboard-preferences.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘自定义偏好 Hook(L4)。
|
||||||
|
*
|
||||||
|
* 从 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/modules/dashboard/hooks/use-dashboard-realtime.ts
Normal file
80
src/modules/dashboard/hooks/use-dashboard-realtime.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘实时更新 Hook(L6)。
|
||||||
|
*
|
||||||
|
* 基于 Server-Sent Events (SSE) 实现轻量级实时更新,
|
||||||
|
* 避免引入 WebSocket 的复杂依赖。
|
||||||
|
*
|
||||||
|
* - 自动重连(指数退避,最大 30s)
|
||||||
|
* - 组件卸载时自动清理 EventSource
|
||||||
|
* - 连接状态可查询(connecting/connected/error)
|
||||||
|
*
|
||||||
|
* @param url SSE 端点 URL
|
||||||
|
* @param eventName 监听的事件名(默认 "update")
|
||||||
|
*/
|
||||||
|
export type ConnectionStatus = "connecting" | "connected" | "error" | "closed"
|
||||||
|
|
||||||
|
export function useDashboardRealtime<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 }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
118
src/modules/dashboard/services/dashboard-service.tsx
Normal file
118
src/modules/dashboard/services/dashboard-service.tsx
Normal 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)
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,7 +127,8 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 概览 */}
|
{/* v2-P1-6: 概览区块独立 Error Boundary */}
|
||||||
|
<WidgetBoundary title={t("summary.class")} skeletonHeight={140}>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -162,8 +163,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +139,13 @@ export function StudentDiagnosticView({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 雷达图 */}
|
{/* v2-P1-6: 雷达图区块独立 Error Boundary */}
|
||||||
|
<WidgetBoundary title={t("chart.radarTitle")} skeletonHeight={384}>
|
||||||
<MasteryRadarChart data={radarData} />
|
<MasteryRadarChart data={radarData} />
|
||||||
|
</WidgetBoundary>
|
||||||
|
|
||||||
{/* 强项 / 弱项 */}
|
{/* v2-P1-6: 强项 / 弱项区块独立 Error Boundary */}
|
||||||
|
<WidgetBoundary title={t("strengths.title")} skeletonHeight={300}>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
// grade_managed 和 all 不在此过滤(grade_managed 需要跨模块查询年级学生,由调用方自行过滤)
|
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))
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -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[] = []
|
||||||
|
|||||||
@@ -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 }>
|
||||||
|
rows: Array<Record<string, string | number>>
|
||||||
|
}> = [
|
||||||
{
|
{
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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 })
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/modules/diagnostic/role-config.ts
Normal file
40
src/modules/diagnostic/role-config.ts
Normal 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]
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
91
src/modules/diagnostic/services/diagnostic-monitor.ts
Normal file
91
src/modules/diagnostic/services/diagnostic-monitor.ts
Normal 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
|
||||||
|
/** 报告 ID(publish/delete/export 事件必填) */
|
||||||
|
reportId?: string
|
||||||
|
/** 学生 ID(individual 报告) */
|
||||||
|
studentId?: string
|
||||||
|
/** 班级 ID(class 报告或 class_kp_filtered 事件) */
|
||||||
|
classId?: string
|
||||||
|
/** 年级 ID(grade 报告) */
|
||||||
|
gradeId?: string
|
||||||
|
/** 知识点 ID(class_kp_filtered 事件) */
|
||||||
|
knowledgePointId?: string
|
||||||
|
/** 报告周期(report_generated 事件,格式 YYYY-MM) */
|
||||||
|
period?: string
|
||||||
|
/** 掌握度阈值(class_kp_filtered 事件) */
|
||||||
|
threshold?: number
|
||||||
|
/** 操作是否成功(便于计算转化率) */
|
||||||
|
success?: boolean
|
||||||
|
/** 错误信息(失败时记录,便于排查) */
|
||||||
|
error?: string
|
||||||
|
/** 操作耗时(毫秒,便于性能监控) */
|
||||||
|
durationMs?: number
|
||||||
|
/** 触发操作的用户 ID */
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断模块监控接口。
|
||||||
|
*
|
||||||
|
* 所有方法均为 async(即使 no-op 也返回 Promise),
|
||||||
|
* 以便真实实现可异步发送事件而不阻塞调用方。
|
||||||
|
*/
|
||||||
|
export interface DiagnosticMonitor {
|
||||||
|
/**
|
||||||
|
* 追踪诊断模块事件。
|
||||||
|
*
|
||||||
|
* @param eventName 事件名称
|
||||||
|
* @param properties 事件属性(可选)
|
||||||
|
*/
|
||||||
|
track(
|
||||||
|
eventName: DiagnosticEventName,
|
||||||
|
properties?: DiagnosticEventProperties,
|
||||||
|
): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认 no-op 实现:不发送任何事件,仅静默返回。
|
||||||
|
*
|
||||||
|
* 在未注入真实监控实现时使用,确保业务流程不受影响。
|
||||||
|
*/
|
||||||
|
export const noopDiagnosticMonitor: DiagnosticMonitor = {
|
||||||
|
async track(): Promise<void> {
|
||||||
|
// no-op: 默认不发送任何事件
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
58
src/modules/diagnostic/services/diagnostic-service.ts
Normal file
58
src/modules/diagnostic/services/diagnostic-service.ts
Normal 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[]>>
|
||||||
|
}
|
||||||
158
src/modules/diagnostic/services/monitored-diagnostic-service.ts
Normal file
158
src/modules/diagnostic/services/monitored-diagnostic-service.ts
Normal 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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验当前用户对课程的管理权限(资源归属校验)。
|
* 校验当前用户对课程的管理权限(资源归属校验)。
|
||||||
* - admin(scope=all):直接放行
|
* - admin(scope=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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
198
src/modules/elective/components/elective-course-detail.tsx
Normal file
198
src/modules/elective/components/elective-course-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" ? (
|
||||||
|
|||||||
71
src/modules/elective/components/elective-stats-cards.tsx
Normal file
71
src/modules/elective/components/elective-stats-cards.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/modules/elective/components/parent-selection-view.tsx
Normal file
85
src/modules/elective/components/parent-selection-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,50 +37,47 @@ 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 handleSelect = (courseId: string) => {
|
|
||||||
setPendingId(courseId)
|
|
||||||
startTransition(async () => {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.set("courseId", courseId)
|
|
||||||
const res = await selectCourseAction(null, formData)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message || t("student.selectSuccess"))
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message ?? t("errors.unexpected"))
|
|
||||||
}
|
|
||||||
setPendingId(null)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (courseId: string) => {
|
const handleDrop = (courseId: string) => {
|
||||||
setPendingId(courseId)
|
setPendingId(courseId)
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.set("courseId", courseId)
|
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)
|
const res = await dropCourseAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || t("student.dropSuccess"))
|
toast.success(res.message || t("student.dropSuccess"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
|
// 清空该课程的退课理由
|
||||||
|
setDropReasonMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[courseId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("errors.unexpected"))
|
toast.error(res.message ?? t("errors.unexpected"))
|
||||||
}
|
}
|
||||||
@@ -87,11 +86,15 @@ export function StudentSelectionView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<section className="space-y-4" aria-labelledby="student-my-selections-heading">
|
||||||
<section className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">{t("student.mySelections")}</h3>
|
<h3
|
||||||
<span className="text-sm text-muted-foreground">
|
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}
|
{activeSelections.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +147,25 @@ export function StudentSelectionView({
|
|||||||
{t("student.confirmDrop")}
|
{t("student.confirmDrop")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</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>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
<AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
@@ -160,11 +182,53 @@ export function StudentSelectionView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<section className="space-y-4">
|
/**
|
||||||
|
* 可选课程区块
|
||||||
|
* - 独立 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) => {
|
||||||
|
setPendingId(courseId)
|
||||||
|
startTransition(async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("courseId", courseId)
|
||||||
|
const res = await selectCourseAction(null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message || t("student.selectSuccess"))
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("errors.unexpected"))
|
||||||
|
}
|
||||||
|
setPendingId(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4" aria-labelledby="student-available-courses-heading">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">{t("student.availableCourses")}</h3>
|
<h3
|
||||||
<span className="text-sm text-muted-foreground">
|
id="student-available-courses-heading"
|
||||||
|
className="text-lg font-semibold"
|
||||||
|
>
|
||||||
|
{t("student.availableCourses")}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||||
{availableCourses.length}
|
{availableCourses.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,6 +307,5 @@ export function StudentSelectionView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
for (const existingSlot of existingSlots) {
|
||||||
|
if (isScheduleConflict(newSlot, existingSlot)) {
|
||||||
return true
|
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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
97
src/modules/elective/data-access-settings.ts
Normal file
97
src/modules/elective/data-access-settings.ts
Normal 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. 若未配置或未传入 gradeId,fallback 到 `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.9(90%)。
|
||||||
|
*/
|
||||||
|
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
|
||||||
88
src/modules/elective/data-access-stats.ts
Normal file
88
src/modules/elective/data-access-stats.ts
Normal 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 个 SQL(pendingLottery 需 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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,7 +135,6 @@ 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(
|
||||||
@@ -158,26 +159,17 @@ export const getElectiveCourses = cache(
|
|||||||
if (rows.length === 0) return []
|
if (rows.length === 0) return []
|
||||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||||
} catch (error) {
|
|
||||||
console.error("getElectiveCourses failed:", error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user