refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y
V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复: - P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions / teacher-classes-card / teacher-homework-card / teacher-schedule / recent-submissions / teacher-grade-trends / student-grades-card / student-today-schedule-card / student-upcoming-assignments-card / admin-dashboard),新增 ~50 个翻译键 - P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student 头部 90% 重复代码,两个 Header 改为薄包装 - P2 单测:为 6 个纯函数添加 31 个单元测试 (tests/integration/dashboard/dashboard-utils.test.ts) - P2 a11y:admin 表格 caption、teacher/student 视图语义化标签 (header / section aria-label / aside aria-label) - 同步架构图 004/005
This commit is contained in:
@@ -195,6 +195,7 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
|
||||
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
||||
) : (
|
||||
<Table>
|
||||
<caption className="sr-only">{t("sections.recentUsers")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("table.name")}</TableHead>
|
||||
@@ -209,7 +210,7 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
|
||||
<TableCell className="font-medium">{u.name || "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
|
||||
<Badge variant="secondary">{u.role ?? t("badge.unknown")}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
|
||||
/**
|
||||
* 仪表盘问候语头部(共享组件)
|
||||
*
|
||||
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
|
||||
* 通过 `actions` slot 注入角色专属快捷操作。
|
||||
*/
|
||||
export function DashboardGreetingHeader({
|
||||
userName,
|
||||
actions,
|
||||
}: {
|
||||
userName: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{userName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
|
||||
interface StudentDashboardHeaderProps {
|
||||
studentName: string
|
||||
}
|
||||
|
||||
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{studentName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardGreetingHeader userName={studentName} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
||||
|
||||
import { DashboardSection } from "../dashboard-section"
|
||||
@@ -17,9 +19,12 @@ export async function StudentDashboard({
|
||||
upcomingAssignments,
|
||||
grades,
|
||||
}: StudentDashboardProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StudentDashboardHeader studentName={studentName} />
|
||||
<header>
|
||||
<StudentDashboardHeader studentName={studentName} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats">
|
||||
<StudentStatsGrid
|
||||
@@ -32,19 +37,22 @@ export async function StudentDashboard({
|
||||
</DashboardSection>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<section
|
||||
aria-label={t("sections.upcomingAssignments")}
|
||||
className="lg:col-span-2 space-y-6"
|
||||
>
|
||||
<DashboardSection variant="list">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="card">
|
||||
<StudentGradesCard grades={grades} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
</section>
|
||||
<aside aria-label={t("sections.todaySchedule")} className="space-y-6">
|
||||
<DashboardSection variant="card">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
@@ -10,6 +12,7 @@ import { formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
|
||||
const t = useTranslations("dashboard")
|
||||
const hasGradeTrend = grades.trend.length > 0
|
||||
const hasRecentGrades = grades.recent.length > 0
|
||||
|
||||
@@ -26,13 +29,20 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Recent Grades"
|
||||
title={t("sections.recentGrades")}
|
||||
icon={BarChart3}
|
||||
iconClassName="text-muted-foreground"
|
||||
isEmpty={!hasGradeTrend}
|
||||
emptyTitle="No graded work yet"
|
||||
emptyDescription="Finish and submit assignments to see your score trend."
|
||||
emptyTitle={t("empty.noGradedWork")}
|
||||
emptyDescription={t("empty.noGradedWorkDesc")}
|
||||
emptyClassName="h-72"
|
||||
action={
|
||||
hasGradeTrend ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/student/grades">{t("quickActions.viewAll")}</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
@@ -41,7 +51,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
series={[
|
||||
{
|
||||
dataKey: "score",
|
||||
name: "Score (%)",
|
||||
name: t("chart.scorePercent"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
@@ -56,13 +66,13 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
{latestGrade ? (
|
||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>
|
||||
Latest:{" "}
|
||||
{t("chart.latest")}:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{Math.round(latestGrade.percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Points:{" "}
|
||||
{t("chart.points")}:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{latestGrade.score}/{latestGrade.maxScore}
|
||||
</span>
|
||||
@@ -76,9 +86,9 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.assignment")}</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.score")}</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.when")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { CalendarDays, CalendarX } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -12,18 +13,15 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
|
||||
|
||||
/**
|
||||
* Parse "HH:MM" time string into minutes since midnight for comparison.
|
||||
*/
|
||||
const timeToMinutes = (t: string): number => {
|
||||
const [h, m] = t.split(":").map(Number)
|
||||
return (h ?? 0) * 60 + (m ?? 0)
|
||||
}
|
||||
|
||||
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
|
||||
const t = useTranslations("dashboard")
|
||||
const hasSchedule = items.length > 0
|
||||
|
||||
// Compute current/next class status based on client time
|
||||
const { currentId, nextId } = useMemo(() => {
|
||||
const now = new Date()
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes()
|
||||
@@ -49,18 +47,18 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Today's Schedule
|
||||
{t("sections.todaySchedule")}
|
||||
</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/student/schedule">View all</Link>
|
||||
<Link href="/student/schedule">{t("quickActions.viewAll")}</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSchedule ? (
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title="No classes today"
|
||||
description="Your timetable is clear for today."
|
||||
title={t("empty.noClassesToday")}
|
||||
description={t("empty.noClassesTodayDesc")}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
@@ -74,14 +72,14 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
In Progress
|
||||
{t("badge.inProgress")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (isNext) {
|
||||
return (
|
||||
<Badge variant="outline" className="shrink-0 border-primary text-primary">
|
||||
Up Next
|
||||
{t("badge.upNext")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { PenTool } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -14,11 +15,11 @@ import {
|
||||
STUDENT_HOMEWORK_PROGRESS_LABEL,
|
||||
} from "@/modules/homework/types"
|
||||
|
||||
const getActionLabel = (status: string) => {
|
||||
if (status === "graded") return "Review"
|
||||
if (status === "submitted") return "View"
|
||||
if (status === "in_progress") return "Continue"
|
||||
return "Start"
|
||||
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" => {
|
||||
@@ -33,12 +34,13 @@ const getDueUrgency = (dueAt: string | null) => {
|
||||
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
if (diffHours < 0) return "overdue"
|
||||
if (diffHours < 48) return "urgent" // 2 days
|
||||
if (diffHours < 120) return "warning" // 5 days
|
||||
if (diffHours < 48) return "urgent"
|
||||
if (diffHours < 120) return "warning"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasAssignments = upcomingAssignments.length > 0
|
||||
|
||||
return (
|
||||
@@ -46,18 +48,18 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
Upcoming Assignments
|
||||
{t("sections.upcomingAssignments")}
|
||||
</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/student/learning/assignments">View all</Link>
|
||||
<Link href="/student/learning/assignments">{t("quickActions.viewAll")}</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasAssignments ? (
|
||||
<EmptyState
|
||||
icon={PenTool}
|
||||
title="No assignments"
|
||||
description="You have no assigned homework right now."
|
||||
title={t("empty.noAssignmentsStudent")}
|
||||
description={t("empty.noAssignmentsStudentDesc")}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
@@ -65,18 +67,18 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Title</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Action</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.title")}</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.status")}</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.due")}</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.score")}</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">{t("table.action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{upcomingAssignments.map((a) => {
|
||||
const urgency = getDueUrgency(a.dueAt)
|
||||
const isGraded = a.progressStatus === "graded"
|
||||
|
||||
|
||||
return (
|
||||
<TableRow key={a.id} className="h-12">
|
||||
<TableCell className="font-medium">
|
||||
@@ -85,7 +87,7 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
||||
{a.title}
|
||||
</Link>
|
||||
{!isGraded && urgency === "overdue" && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">{t("badge.late")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -107,7 +109,7 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
{t(getActionLabelKey(a.progressStatus))}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { Inbox, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Inbox, ArrowRight } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -13,54 +14,57 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table";
|
||||
import { formatDate } from "@/shared/lib/utils";
|
||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types"
|
||||
|
||||
export function RecentSubmissions({
|
||||
submissions,
|
||||
title = "Recent Submissions",
|
||||
emptyTitle = "No New Submissions",
|
||||
emptyDescription = "All caught up! There are no new submissions to review."
|
||||
}: {
|
||||
submissions: HomeworkSubmissionListItem[],
|
||||
title?: string,
|
||||
emptyTitle?: string,
|
||||
interface RecentSubmissionsProps {
|
||||
submissions: HomeworkSubmissionListItem[]
|
||||
title?: string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}) {
|
||||
const hasSubmissions = submissions.length > 0;
|
||||
}
|
||||
|
||||
export async function RecentSubmissions({
|
||||
submissions,
|
||||
title,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: RecentSubmissionsProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasSubmissions = submissions.length > 0
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
{title}
|
||||
{title ?? t("sections.recentSubmissions")}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-primary" asChild>
|
||||
<Link href="/teacher/homework/submissions" className="flex items-center gap-1">
|
||||
View All <ArrowRight className="h-3 w-3" />
|
||||
{t("quickActions.viewAllSubmissions")} <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{!hasSubmissions ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
|
||||
className="border-none h-full min-h-[200px]"
|
||||
/>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title={emptyTitle ?? t("empty.noNewSubmissions")}
|
||||
description={emptyDescription ?? t("empty.noNewSubmissionsDesc")}
|
||||
action={{ label: t("quickActions.viewAllSubmissions"), href: "/teacher/homework/submissions" }}
|
||||
className="border-none h-full min-h-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[200px]">Student</TableHead>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead className="w-[140px]">Submitted</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Action</TableHead>
|
||||
<TableHead className="w-[200px]">{t("table.student")}</TableHead>
|
||||
<TableHead>{t("table.assignment")}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("table.submitted")}</TableHead>
|
||||
<TableHead className="w-[100px] text-right">{t("table.action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -78,7 +82,7 @@ export function RecentSubmissions({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
<Link
|
||||
href={`/teacher/homework/submissions/${item.id}`}
|
||||
className="font-medium hover:text-primary hover:underline transition-colors block truncate max-w-[240px]"
|
||||
title={item.assignmentTitle}
|
||||
@@ -93,7 +97,7 @@ export function RecentSubmissions({
|
||||
</span>
|
||||
{item.isLate && (
|
||||
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||
Late
|
||||
{t("badge.late")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -101,7 +105,7 @@ export function RecentSubmissions({
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="secondary" className="h-8 px-3" asChild>
|
||||
<Link href={`/teacher/homework/submissions/${item.id}`}>
|
||||
Grade
|
||||
{t("action.grade")}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -113,5 +117,5 @@ export function RecentSubmissions({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import Link from "next/link"
|
||||
import { Users } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
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 type { TeacherClass } from "@/modules/classes/types"
|
||||
|
||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
export async function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
My Classes
|
||||
{t("sections.myClasses")}
|
||||
</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">View all</Link>
|
||||
<Link href="/teacher/classes/my">{t("quickActions.viewAll")}</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No classes yet"
|
||||
description="Create a class to start managing students and schedules."
|
||||
action={{ label: "Create class", href: "/teacher/classes/my" }}
|
||||
title={t("empty.noClassesYet")}
|
||||
description={t("empty.noClassesDesc")}
|
||||
action={{ label: t("quickActions.createClass"), href: "/teacher/classes/my" }}
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
@@ -44,13 +47,13 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
{c.homeroom && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Homeroom: {c.homeroom}</span>
|
||||
<span>{t("schedule.homeroom")}: {c.homeroom}</span>
|
||||
</>
|
||||
)}
|
||||
{c.room && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Room {c.room}</span>
|
||||
<span>{t("schedule.room")} {c.room}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { formatLongDate } from "@/shared/lib/utils"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
interface TeacherDashboardHeaderProps {
|
||||
@@ -10,19 +8,10 @@ interface TeacherDashboardHeaderProps {
|
||||
}
|
||||
|
||||
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||
const t = useTranslations("dashboard")
|
||||
const today = formatLongDate(new Date())
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{teacherName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
<DashboardGreetingHeader
|
||||
userName={teacherName}
|
||||
actions={<TeacherQuickActions />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
<header>
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats">
|
||||
<TeacherStats
|
||||
@@ -57,8 +59,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
</DashboardSection>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-8">
|
||||
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 lg:col-span-8">
|
||||
<div className="lg:hidden">
|
||||
<DashboardSection variant="card">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
@@ -78,9 +79,9 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
emptyDescription={t("empty.allGradedDesc")}
|
||||
/>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:col-span-4">
|
||||
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 lg:col-span-4">
|
||||
<div className="hidden lg:block">
|
||||
<DashboardSection variant="card">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
@@ -92,7 +93,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
||||
<DashboardSection variant="list">
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||
|
||||
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
|
||||
const t = useTranslations("dashboard")
|
||||
const hasTrends = trends.length > 0
|
||||
|
||||
const chartData = trends.map((item) => {
|
||||
@@ -22,14 +24,14 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Class Performance"
|
||||
description={`Average scores for the last ${trends.length} assignments`}
|
||||
title={t("sections.classPerformance")}
|
||||
description={t("chart.classPerformanceDesc", { count: trends.length })}
|
||||
icon={TrendingUp}
|
||||
iconClassName="text-primary"
|
||||
titleClassName="text-base font-medium"
|
||||
isEmpty={!hasTrends}
|
||||
emptyTitle="No data available"
|
||||
emptyDescription="Publish assignments to see class performance trends."
|
||||
emptyTitle={t("empty.noData")}
|
||||
emptyDescription={t("empty.noDataDesc")}
|
||||
emptyClassName="h-[200px] p-0"
|
||||
className="col-span-1"
|
||||
>
|
||||
@@ -39,7 +41,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
series={[
|
||||
{
|
||||
dataKey: "score",
|
||||
name: "Average Score (%)",
|
||||
name: t("chart.averageScorePercent"),
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
@@ -65,7 +67,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
||||
<span className="text-xl font-bold tabular-nums">{item.score}%</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.submissionCount}/{item.totalStudents} submitted
|
||||
{t("chart.submittedCount", { submitted: item.submissionCount, total: item.totalStudents })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { PenTool, Calendar, Plus } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -8,16 +9,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
|
||||
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||
export async function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
Homework
|
||||
{t("sections.homework")}
|
||||
</CardTitle>
|
||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Link href="/teacher/homework/assignments/create" title="Create new assignment">
|
||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8" title={t("quickActions.createNewAssignment")}>
|
||||
<Link href="/teacher/homework/assignments/create">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -26,9 +29,9 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PenTool}
|
||||
title="No assignments"
|
||||
description="Create an assignment to get started."
|
||||
action={{ label: "Create", href: "/teacher/homework/assignments/create" }}
|
||||
title={t("empty.noAssignments")}
|
||||
description={t("empty.noAssignmentsDesc")}
|
||||
action={{ label: t("quickActions.create"), href: "/teacher/homework/assignments/create" }}
|
||||
className="border-none h-48"
|
||||
/>
|
||||
) : (
|
||||
@@ -36,7 +39,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{assignments.slice(0, 6).map((a) => {
|
||||
const isPublished = a.status === "published"
|
||||
const isDraft = a.status === "draft"
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={a.id}
|
||||
@@ -47,7 +50,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<div className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isPublished ? "bg-emerald-500" :
|
||||
isPublished ? "bg-emerald-500" :
|
||||
isDraft ? "bg-amber-400" : "bg-muted-foreground"
|
||||
)} />
|
||||
<div className="font-medium truncate text-sm group-hover:text-primary transition-colors">
|
||||
@@ -58,7 +61,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{a.sourceExamTitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{a.dueAt ? (
|
||||
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
||||
@@ -66,10 +69,10 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
{formatDate(a.dueAt)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">No due date</span>
|
||||
<span className="text-[10px] text-muted-foreground italic">{t("schedule.noDueDate")}</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] h-4 px-1.5 capitalize font-normal border-transparent bg-muted/50",
|
||||
isPublished && "text-emerald-600 bg-emerald-500/10",
|
||||
@@ -84,7 +87,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
||||
})}
|
||||
<div className="pt-2">
|
||||
<Button asChild variant="link" size="sm" className="w-full text-muted-foreground h-auto py-1 text-xs">
|
||||
<Link href="/teacher/homework/assignments">View all assignments</Link>
|
||||
<Link href="/teacher/homework/assignments">{t("quickActions.viewAllAssignments")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import Link from "next/link";
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { PlusCircle, CheckSquare, Users } from "lucide-react";
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, CheckSquare, Users } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function TeacherQuickActions() {
|
||||
const t = useTranslations("dashboard")
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/teacher/homework/assignments/create">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Create Assignment
|
||||
{t("quickActions.createAssignment")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/homework/submissions">
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
Grade
|
||||
{t("quickActions.grade")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
My Classes
|
||||
{t("quickActions.myClasses")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,150 +1,147 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { CalendarDays, CalendarX, MapPin } from "lucide-react";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { CalendarDays, CalendarX, MapPin } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
|
||||
type TeacherTodayScheduleItem = {
|
||||
id: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
course: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
};
|
||||
id: string
|
||||
classId: string
|
||||
className: string
|
||||
course: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
location: string | null
|
||||
}
|
||||
|
||||
export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasSchedule = items.length > 0
|
||||
|
||||
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const hasSchedule = items.length > 0;
|
||||
|
||||
const getStatus = (start: string, end: string) => {
|
||||
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 = startH * 60 + startM;
|
||||
const endTime = endH * 60 + endM;
|
||||
const now = new Date()
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live";
|
||||
if (currentTime < startTime) return "upcoming";
|
||||
return "past";
|
||||
};
|
||||
const [startH, startM] = start.split(":").map(Number)
|
||||
const [endH, endM] = end.split(":").map(Number)
|
||||
const startTime = (startH ?? 0) * 60 + (startM ?? 0)
|
||||
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||
if (currentTime < startTime) return "upcoming"
|
||||
return "past"
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Today's Schedule
|
||||
{t("sections.todaySchedule")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{!hasSchedule ? (
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title="No Classes Today"
|
||||
description="No timetable entries."
|
||||
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
||||
className="border-none h-[200px]"
|
||||
/>
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title={t("empty.noClassesToday")}
|
||||
description={t("empty.noClassesTodayDesc")}
|
||||
action={{ label: t("quickActions.viewSchedule"), href: "/teacher/classes/schedule" }}
|
||||
className="border-none h-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="h-[240px] px-6 py-2">
|
||||
<div className="relative space-y-0 ml-1">
|
||||
{/* Vertical Timeline Line */}
|
||||
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
|
||||
|
||||
{/* Top Fade Hint */}
|
||||
|
||||
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
||||
|
||||
{items.map((item, index) => {
|
||||
const status = getStatus(item.startTime, item.endTime);
|
||||
const isLive = status === "live";
|
||||
const isPast = status === "past";
|
||||
const isLast = index === items.length - 1;
|
||||
const status = getStatus(item.startTime, item.endTime)
|
||||
const isLive = status === "live"
|
||||
const isPast = status === "past"
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||
{/* Timeline Dot */}
|
||||
<div className={cn(
|
||||
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||
isLive ? "bg-primary border-primary" :
|
||||
isPast ? "bg-muted border-muted-foreground/30" :
|
||||
"bg-background border-primary"
|
||||
)} />
|
||||
return (
|
||||
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||
<div className={cn(
|
||||
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||
isLive ? "bg-primary border-primary" :
|
||||
isPast ? "bg-muted border-muted-foreground/30" :
|
||||
"bg-background border-primary"
|
||||
)} />
|
||||
|
||||
<Link
|
||||
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
||||
className={cn(
|
||||
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
||||
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
||||
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
||||
"bg-card"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"font-medium text-sm truncate",
|
||||
isLive ? "text-primary" : "text-foreground"
|
||||
)}>
|
||||
{item.course}
|
||||
</span>
|
||||
{isLive && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
||||
LIVE
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||
<span>{item.className}</span>
|
||||
{item.location && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center">
|
||||
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
||||
{item.location}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
||||
className={cn(
|
||||
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
||||
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
||||
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
||||
"bg-card"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"font-medium text-sm truncate",
|
||||
isLive ? "text-primary" : "text-foreground"
|
||||
)}>
|
||||
{item.course}
|
||||
</span>
|
||||
{isLive && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
||||
{t("badge.live")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
||||
isLive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.startTime}
|
||||
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||
<span>{item.className}</span>
|
||||
{item.location && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center">
|
||||
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
||||
{item.location}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Connection Line to Next (if not last) */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className={cn(
|
||||
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
||||
isLive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.startTime}
|
||||
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{!isLast && (
|
||||
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Bottom Hint */}
|
||||
|
||||
{items.length > 3 ? (
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
||||
Scroll for more
|
||||
</div>
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
||||
{t("schedule.scrollForMore")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||
No more classes today
|
||||
</div>
|
||||
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||
{t("schedule.noMoreClasses")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||
import type { LessonPlanNode, RichTextBlockData } from "../types";
|
||||
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData } from "../types";
|
||||
|
||||
interface BlockRendererProps {
|
||||
textbookId?: string;
|
||||
@@ -112,13 +112,13 @@ function SortableBlock({
|
||||
) : node.type === "exercise" ? (
|
||||
<ExerciseBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
data={node.data as ExerciseBlockData}
|
||||
classes={classes ?? []}
|
||||
/>
|
||||
) : node.type === "text_study" ? (
|
||||
<TextStudyBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
data={node.data as TextStudyBlockData}
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
|
||||
@@ -84,8 +84,8 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
: t("questionBank.inlineQuestion")}
|
||||
</span>
|
||||
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
|
||||
<button onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
|
||||
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -97,7 +97,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
size="sm"
|
||||
onClick={() => setShowBank(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("questionBank.fromBank")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -105,7 +105,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
size="sm"
|
||||
onClick={() => setShowInline(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("questionBank.inlineNew")}
|
||||
</Button>
|
||||
{data.publishedAssignmentId ? (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
@@ -37,7 +38,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
||||
|
||||
function addAnnotation() {
|
||||
if (!selection) {
|
||||
alert(t("textStudy.selectFirst"));
|
||||
toast.error(t("textStudy.selectFirst"));
|
||||
return;
|
||||
}
|
||||
const ann: TextStudyAnnotation = {
|
||||
@@ -76,7 +77,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
||||
onClick={addAnnotation}
|
||||
disabled={!selection}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("textStudy.addAnnotation")}
|
||||
</Button>
|
||||
{data.annotations.length > 0 && (
|
||||
@@ -100,8 +101,8 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
||||
}
|
||||
className="font-medium text-sm bg-transparent flex-1"
|
||||
/>
|
||||
<button onClick={() => removeAnnotation(ann.id)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
<button onClick={() => removeAnnotation(ann.id)} aria-label={t("action.delete")}>
|
||||
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X, Tag } from "lucide-react";
|
||||
@@ -27,9 +28,15 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
const [kpIds, setKpIds] = useState<string[]>([]);
|
||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||
|
||||
// 类型守卫:安全地将 string 收窄为联合类型
|
||||
const QUESTION_TYPES = ["single_choice", "text", "judgment"] as const;
|
||||
function isQuestionType(v: string): v is "single_choice" | "text" | "judgment" {
|
||||
return QUESTION_TYPES.includes(v as typeof QUESTION_TYPES[number]);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!text.trim()) {
|
||||
alert(t("questionBank.stemRequired"));
|
||||
toast.error(t("questionBank.stemRequired"));
|
||||
return;
|
||||
}
|
||||
const content: Record<string, unknown> =
|
||||
@@ -63,11 +70,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col" role="dialog" aria-modal="true" aria-label={t("questionBank.inlineTitle")}>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
@@ -75,7 +82,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as never)}
|
||||
onChange={(e) => {
|
||||
if (isQuestionType(e.target.value)) {
|
||||
setType(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
>
|
||||
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
|
||||
@@ -120,7 +131,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
setOptions(options.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
删除
|
||||
{t("action.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,11 +47,16 @@ export function KnowledgePointPicker({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("knowledgePoint.title")}
|
||||
className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
||||
@@ -3,14 +3,51 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import { useLessonPlanContextSafe, useRoleConfig } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
const roleConfig = useRoleConfig();
|
||||
|
||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleArchive() {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.delete"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||
@@ -36,27 +73,34 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
: t("list.neverSaved")}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const res = await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
}}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(t("confirm.archive"))) return;
|
||||
const res = await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
}}
|
||||
>
|
||||
{t("action.archive")}
|
||||
</Button>
|
||||
{roleConfig.canDuplicate && (
|
||||
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
||||
{t("action.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{roleConfig.canArchive && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{t("action.archive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("confirm.archiveTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirm.archive")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleArchive}>
|
||||
{t("action.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ export function LessonPlanEditor({
|
||||
<button
|
||||
key={blockType}
|
||||
onClick={() => {
|
||||
editor.addNode(blockType);
|
||||
editor.addNode(blockType, undefined, t(`blockType.${blockType}`));
|
||||
setShowAddMenu(false);
|
||||
}}
|
||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
import { getLessonPlansAction } from "../actions";
|
||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -15,12 +16,19 @@ interface Props {
|
||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleFilter(params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
}
|
||||
|
||||
@@ -53,11 +53,16 @@ export function PublishHomeworkDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("publish.title")}
|
||||
className="bg-surface rounded-lg shadow-xl w-96"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">{t("publish.title")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
|
||||
@@ -92,11 +92,16 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("questionBank.title")}
|
||||
className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">{t("questionBank.title")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 border-b">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TemplatePicker() {
|
||||
: "border-outline-variant hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-title-md">{tpl.name}</div>
|
||||
<div className="font-title-md">{t(`template.names.${tpl.id}`)}</div>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{tpl.blocks.length === 0
|
||||
? t("template.blankHint")
|
||||
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
} from "../actions";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import type { LessonPlanVersion } from "../types";
|
||||
|
||||
@@ -46,13 +58,13 @@ export function VersionHistoryDrawer({
|
||||
}, [open, planId]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
if (!confirm(t("version.revertConfirm", { versionNo }))) return;
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
alert(res.message);
|
||||
toast.error(res.message ?? t("error.revert"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +99,27 @@ export function VersionHistoryDrawer({
|
||||
<p className="text-xs text-on-surface-variant mt-1">
|
||||
{formatDateTime(v.createdAt)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => handleRevert(v.versionNo)}
|
||||
>
|
||||
{t("version.revert")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="mt-2">
|
||||
{t("version.revert")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("version.revertTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("version.revertConfirm", { versionNo: v.versionNo })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleRevert(v.versionNo)}>
|
||||
{t("action.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types";
|
||||
|
||||
// block 类型 → 中文默认标题
|
||||
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
objective: "教学目标",
|
||||
key_point: "教学重难点",
|
||||
import: "导入",
|
||||
new_teaching: "新授",
|
||||
consolidation: "巩固练习",
|
||||
summary: "课堂小结",
|
||||
homework: "作业布置",
|
||||
blackboard: "板书设计",
|
||||
text_study: "文本研习",
|
||||
exercise: "练习/作业",
|
||||
rich_text: "自定义环节",
|
||||
reflection: "教学反思",
|
||||
// block 类型 → i18n 键(实际文本由 useTranslations("lessonPreparation").blockType.${type} 翻译)
|
||||
// 保留此映射用于:1) 新节点默认标题的 i18n 键查找 2) 类型守卫
|
||||
export const BLOCK_TYPE_KEYS: Record<BlockType, string> = {
|
||||
objective: "objective",
|
||||
key_point: "key_point",
|
||||
import: "import",
|
||||
new_teaching: "new_teaching",
|
||||
consolidation: "consolidation",
|
||||
summary: "summary",
|
||||
homework: "homework",
|
||||
blackboard: "blackboard",
|
||||
text_study: "text_study",
|
||||
exercise: "exercise",
|
||||
rich_text: "rich_text",
|
||||
reflection: "reflection",
|
||||
};
|
||||
|
||||
// 向后兼容:保留 BLOCK_TYPE_LABELS 名称但值为 i18n 键(实际翻译由组件层完成)
|
||||
// @deprecated 使用 BLOCK_TYPE_KEYS 或 useTranslations("lessonPreparation").blockType.${type} 替代
|
||||
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = BLOCK_TYPE_KEYS;
|
||||
|
||||
// 富文本类 block(共享同一编辑组件)
|
||||
export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
|
||||
"objective",
|
||||
@@ -100,8 +105,11 @@ export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
export const LESSON_PLAN_STATUS_KEYS: Record<string, string> = {
|
||||
draft: "draft",
|
||||
published: "published",
|
||||
archived: "archived",
|
||||
};
|
||||
|
||||
// @deprecated 使用 useTranslations("lessonPreparation").status.${key} 替代
|
||||
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = LESSON_PLAN_STATUS_KEYS;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { and, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
@@ -32,23 +32,53 @@ import type {
|
||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
||||
|
||||
// ---- DataScope → 查询条件 ----
|
||||
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
|
||||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||
switch (scope.type) {
|
||||
case "all":
|
||||
return [];
|
||||
case "owned":
|
||||
return [eq(lessonPlans.creatorId, userId)];
|
||||
case "class_taught":
|
||||
case "grade_managed":
|
||||
case "class_members":
|
||||
case "children":
|
||||
// 教师看自己创建的 + published 的
|
||||
case "class_taught": {
|
||||
// 教师:自己创建的 + published 且属于自己教授学科的
|
||||
const own = eq(lessonPlans.creatorId, userId);
|
||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||
const subjectFilter =
|
||||
scope.subjectIds && scope.subjectIds.length > 0
|
||||
? inArray(lessonPlans.subjectId, scope.subjectIds)
|
||||
: sql<boolean>`true`;
|
||||
return [
|
||||
or(
|
||||
eq(lessonPlans.creatorId, userId),
|
||||
eq(lessonPlans.status, "published"),
|
||||
own,
|
||||
and(publishedFilter, subjectFilter),
|
||||
)!,
|
||||
];
|
||||
}
|
||||
case "grade_managed": {
|
||||
// 教研组长/年级主任:自己创建的 + published 且属于自己管理的年级
|
||||
const own = eq(lessonPlans.creatorId, userId);
|
||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||
const gradeFilter =
|
||||
scope.gradeIds.length > 0
|
||||
? inArray(lessonPlans.gradeId, scope.gradeIds)
|
||||
: sql<boolean>`false`;
|
||||
return [
|
||||
or(
|
||||
own,
|
||||
and(publishedFilter, gradeFilter),
|
||||
)!,
|
||||
];
|
||||
}
|
||||
case "class_members": {
|
||||
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
|
||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||
return [publishedFilter];
|
||||
}
|
||||
case "children": {
|
||||
// 家长:仅查看 published 课案(同学生)
|
||||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||
return [publishedFilter];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
LessonPlanEdge,
|
||||
LessonPlanNode,
|
||||
} from "../types";
|
||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
||||
|
||||
interface EditorState {
|
||||
planId: string;
|
||||
@@ -24,7 +23,7 @@ interface EditorState {
|
||||
setPlanId: (planId: string) => void;
|
||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||
|
||||
addNode: (type: BlockType, position?: { x: number; y: number }) => string;
|
||||
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
|
||||
updateNode: (id: string, patch: Partial<Block>) => void;
|
||||
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
||||
removeNode: (id: string) => void;
|
||||
@@ -76,13 +75,13 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
selectedNodeId: null,
|
||||
}),
|
||||
|
||||
addNode: (type, position) => {
|
||||
addNode: (type, position, title) => {
|
||||
const id = createId();
|
||||
const nodeCount = get().doc.nodes.length;
|
||||
const node: LessonPlanNode = {
|
||||
id,
|
||||
type,
|
||||
title: BLOCK_TYPE_LABELS[type],
|
||||
title: title ?? type, // 调用方应传入翻译后的标题,fallback 为 type 键
|
||||
data: defaultData(type),
|
||||
order: nodeCount,
|
||||
position: position ?? {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { LessonPlanListItem, LessonPlanVersion } from "../types";
|
||||
|
||||
/**
|
||||
* 备课模块数据服务接口(P1-7)。
|
||||
* 抽象数据依赖,各角色/测试可提供不同实现,通过 LessonPlanProvider 注入。
|
||||
* 组件不直接 import actions,只通过此接口调用。
|
||||
*/
|
||||
export interface LessonPlanDataService {
|
||||
/** 查询课案列表 */
|
||||
getLessonPlans(params?: {
|
||||
query?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}): Promise<{ success: boolean; data?: { items: LessonPlanListItem[] }; message?: string }>;
|
||||
|
||||
/** 获取课案版本列表 */
|
||||
getLessonPlanVersions(planId: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { versions: LessonPlanVersion[] };
|
||||
message?: string;
|
||||
}>;
|
||||
|
||||
/** 回退到指定版本 */
|
||||
revertLessonPlanVersion(params: {
|
||||
planId: string;
|
||||
versionNo: number;
|
||||
}): Promise<{ success: boolean; message?: string }>;
|
||||
|
||||
/** 复制课案 */
|
||||
duplicateLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||
|
||||
/** 删除/归档课案 */
|
||||
deleteLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色配置(P1-7):决定该模块渲染哪些 Widget/子模块/操作。
|
||||
* 新增角色只需新增配置项,不改组件代码。
|
||||
*/
|
||||
export interface LessonPlanRoleConfig {
|
||||
/** 是否显示"新建课案"按钮 */
|
||||
canCreate: boolean;
|
||||
/** 是否显示"编辑"入口 */
|
||||
canEdit: boolean;
|
||||
/** 是否显示"发布为作业" */
|
||||
canPublish: boolean;
|
||||
/** 是否显示"复制" */
|
||||
canDuplicate: boolean;
|
||||
/** 是否显示"归档/删除" */
|
||||
canArchive: boolean;
|
||||
/** 是否显示"版本历史" */
|
||||
canViewVersions: boolean;
|
||||
/** 是否显示"AI 知识点建议" */
|
||||
canUseAiSuggest: boolean;
|
||||
/** 是否只读模式(学生/家长查看 published 课案) */
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** 默认教师配置 */
|
||||
export const TEACHER_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canPublish: true,
|
||||
canDuplicate: true,
|
||||
canArchive: true,
|
||||
canViewVersions: true,
|
||||
canUseAiSuggest: true,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
/** 管理员配置(查看全校课案,不可编辑) */
|
||||
export const ADMIN_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||
canCreate: false,
|
||||
canEdit: false,
|
||||
canPublish: false,
|
||||
canDuplicate: false,
|
||||
canArchive: false,
|
||||
canViewVersions: true,
|
||||
canUseAiSuggest: false,
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
/** 学生配置(仅查看 published) */
|
||||
export const STUDENT_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||
canCreate: false,
|
||||
canEdit: false,
|
||||
canPublish: false,
|
||||
canDuplicate: false,
|
||||
canArchive: false,
|
||||
canViewVersions: false,
|
||||
canUseAiSuggest: false,
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
/** 家长配置(查看孩子的 published 课案) */
|
||||
export const PARENT_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||
canCreate: false,
|
||||
canEdit: false,
|
||||
canPublish: false,
|
||||
canDuplicate: false,
|
||||
canArchive: false,
|
||||
canViewVersions: false,
|
||||
canUseAiSuggest: false,
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
/** 角色配置注册表 */
|
||||
export const ROLE_CONFIGS: Record<string, LessonPlanRoleConfig> = {
|
||||
admin: ADMIN_ROLE_CONFIG,
|
||||
teacher: TEACHER_ROLE_CONFIG,
|
||||
student: STUDENT_ROLE_CONFIG,
|
||||
parent: PARENT_ROLE_CONFIG,
|
||||
};
|
||||
|
||||
/** 监控埋点接口(P2-4):预留关键操作埋点 */
|
||||
export interface LessonPlanTracker {
|
||||
track(event: string, payload?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/** 默认空实现埋点(生产环境可替换为真实埋点) */
|
||||
export const noopTracker: LessonPlanTracker = {
|
||||
track: () => {},
|
||||
};
|
||||
|
||||
/** 备课模块上下文值 */
|
||||
export interface LessonPlanContextValue {
|
||||
/** 数据服务(抽象数据依赖) */
|
||||
service: LessonPlanDataService;
|
||||
/** 角色配置 */
|
||||
roleConfig: LessonPlanRoleConfig;
|
||||
/** 监控埋点 */
|
||||
tracker: LessonPlanTracker;
|
||||
}
|
||||
|
||||
const LessonPlanContext = createContext<LessonPlanContextValue | null>(null);
|
||||
|
||||
/** Provider 组件:注入数据服务、角色配置、埋点 */
|
||||
export function LessonPlanProvider({
|
||||
children,
|
||||
service,
|
||||
roleConfig,
|
||||
tracker = noopTracker,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
service: LessonPlanDataService;
|
||||
roleConfig: LessonPlanRoleConfig;
|
||||
tracker?: LessonPlanTracker;
|
||||
}) {
|
||||
const value = useMemo<LessonPlanContextValue>(
|
||||
() => ({ service, roleConfig, tracker }),
|
||||
[service, roleConfig, tracker],
|
||||
);
|
||||
return <LessonPlanContext.Provider value={value}>{children}</LessonPlanContext.Provider>;
|
||||
}
|
||||
|
||||
/** Hook:获取备课模块上下文值(若未在 Provider 内则返回 null,不抛错) */
|
||||
export function useLessonPlanContextSafe(): LessonPlanContextValue | null {
|
||||
return useContext(LessonPlanContext);
|
||||
}
|
||||
|
||||
/** Hook:获取备课模块上下文值(必须在 Provider 内使用) */
|
||||
export function useLessonPlanContext(): LessonPlanContextValue {
|
||||
const ctx = useContext(LessonPlanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useLessonPlanContext 必须在 LessonPlanProvider 内使用");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Hook:获取角色配置(若未在 Provider 内则返回教师默认配置) */
|
||||
export function useRoleConfig(): LessonPlanRoleConfig {
|
||||
const ctx = useContext(LessonPlanContext);
|
||||
return ctx?.roleConfig ?? TEACHER_ROLE_CONFIG;
|
||||
}
|
||||
|
||||
/** Hook:获取数据服务 */
|
||||
export function useLessonPlanService(): LessonPlanDataService {
|
||||
return useLessonPlanContext().service;
|
||||
}
|
||||
|
||||
/** Hook:获取埋点 */
|
||||
export function useLessonPlanTracker(): LessonPlanTracker {
|
||||
return useLessonPlanContext().tracker;
|
||||
}
|
||||
@@ -73,10 +73,17 @@ export async function publishLessonPlanHomework(
|
||||
for (let i = 0; i < newData.items.length; i++) {
|
||||
const item = newData.items[i];
|
||||
if (item.source === "inline" && item.inlineContent) {
|
||||
// 类型守卫:确保 inline 题目类型合法
|
||||
const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const;
|
||||
const qt = item.inlineContent.type;
|
||||
if (!validTypes.includes(qt as typeof validTypes[number])) {
|
||||
throw new Error(`无效的题目类型: ${qt}`);
|
||||
}
|
||||
const questionType = qt as typeof validTypes[number];
|
||||
const questionId = await createQuestionWithRelations(
|
||||
{
|
||||
content: item.inlineContent.content,
|
||||
type: item.inlineContent.type as never,
|
||||
type: questionType,
|
||||
difficulty: item.inlineContent.difficulty,
|
||||
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getLessonPlansAction,
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
duplicateLessonPlanAction,
|
||||
deleteLessonPlanAction,
|
||||
} from "../actions";
|
||||
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
|
||||
|
||||
/**
|
||||
* 默认数据服务实现:包装现有 Server Actions。
|
||||
* 通过 LessonPlanProvider 注入,组件不直接 import actions。
|
||||
* 测试时可替换为 mock 实现。
|
||||
*/
|
||||
export function createDefaultDataService(): LessonPlanDataService {
|
||||
return {
|
||||
async getLessonPlans(params) {
|
||||
const res = await getLessonPlansAction(params ?? {});
|
||||
if (res.success && res.data) {
|
||||
return { success: true, data: { items: res.data.items } };
|
||||
}
|
||||
return { success: false, message: res.message };
|
||||
},
|
||||
|
||||
async getLessonPlanVersions(planId) {
|
||||
const res = await getLessonPlanVersionsAction(planId);
|
||||
if (res.success && res.data) {
|
||||
return { success: true, data: { versions: res.data.versions } };
|
||||
}
|
||||
return { success: false, message: res.message };
|
||||
},
|
||||
|
||||
async revertLessonPlanVersion(params) {
|
||||
const res = await revertLessonPlanVersionAction(params);
|
||||
return { success: res.success, message: res.message };
|
||||
},
|
||||
|
||||
async duplicateLessonPlan(planId) {
|
||||
const res = await duplicateLessonPlanAction(planId);
|
||||
return { success: res.success, message: res.message };
|
||||
},
|
||||
|
||||
async deleteLessonPlan(planId) {
|
||||
const res = await deleteLessonPlanAction(planId);
|
||||
return { success: res.success, message: res.message };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -64,7 +64,17 @@
|
||||
"grades": "Grades",
|
||||
"attendance": "Attendance",
|
||||
"announcements": "Announcements",
|
||||
"leaveRequest": "Leave Request"
|
||||
"leaveRequest": "Leave Request",
|
||||
"createAssignment": "Create Assignment",
|
||||
"grade": "Grade",
|
||||
"myClasses": "My Classes",
|
||||
"viewAll": "View all",
|
||||
"viewSchedule": "View schedule",
|
||||
"viewAllAssignments": "View all assignments",
|
||||
"viewAllSubmissions": "View all submissions",
|
||||
"createNewAssignment": "Create new assignment",
|
||||
"create": "Create",
|
||||
"createClass": "Create class"
|
||||
},
|
||||
"todo": {
|
||||
"title": "Today's To-Do",
|
||||
@@ -86,13 +96,26 @@
|
||||
"upcomingAssignments": "Upcoming Assignments",
|
||||
"grades": "Grades",
|
||||
"myClasses": "My Classes",
|
||||
"gradeTrends": "Grade Trends"
|
||||
"gradeTrends": "Grade Trends",
|
||||
"homework": "Homework",
|
||||
"recentSubmissions": "Recent Submissions",
|
||||
"classPerformance": "Class Performance",
|
||||
"recentGrades": "Recent Grades"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"created": "Created"
|
||||
"created": "Created",
|
||||
"student": "Student",
|
||||
"assignment": "Assignment",
|
||||
"submitted": "Submitted",
|
||||
"action": "Action",
|
||||
"title": "Title",
|
||||
"status": "Status",
|
||||
"due": "Due",
|
||||
"score": "Score",
|
||||
"when": "When"
|
||||
},
|
||||
"empty": {
|
||||
"noUsers": "No users",
|
||||
@@ -105,12 +128,31 @@
|
||||
"noChildrenDesc": "Your account is not linked to any student accounts yet. Please contact the school administrator to link your child.",
|
||||
"noStudent": "No student found",
|
||||
"noStudentDesc": "Create a student user to see dashboard.",
|
||||
"contactSupport": "Contact support"
|
||||
"contactSupport": "Contact support",
|
||||
"noClassesYet": "No classes yet",
|
||||
"noClassesDesc": "Create a class to start managing students and schedules.",
|
||||
"noAssignments": "No assignments",
|
||||
"noAssignmentsDesc": "Create an assignment to get started.",
|
||||
"noAssignmentsStudent": "No assignments",
|
||||
"noAssignmentsStudentDesc": "You have no assigned homework right now.",
|
||||
"noClassesToday": "No classes today",
|
||||
"noClassesTodayDesc": "Your timetable is clear for today.",
|
||||
"noNewSubmissions": "No new submissions",
|
||||
"noNewSubmissionsDesc": "All caught up! There are no new submissions to review.",
|
||||
"noData": "No data available",
|
||||
"noDataDesc": "Publish assignments to see class performance trends.",
|
||||
"noGradedWork": "No graded work yet",
|
||||
"noGradedWorkDesc": "Finish and submit assignments to see your score trend."
|
||||
},
|
||||
"badge": {
|
||||
"activeSessions": "{count} active sessions",
|
||||
"users": "{count} users",
|
||||
"childrenLinked": "{count} children linked"
|
||||
"childrenLinked": "{count} children linked",
|
||||
"live": "LIVE",
|
||||
"late": "Late",
|
||||
"inProgress": "In Progress",
|
||||
"upNext": "Up Next",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Page load failed",
|
||||
@@ -121,6 +163,26 @@
|
||||
},
|
||||
"chart": {
|
||||
"newUsers": "New users",
|
||||
"newSubmissions": "New submissions"
|
||||
"newSubmissions": "New submissions",
|
||||
"averageScorePercent": "Average Score (%)",
|
||||
"scorePercent": "Score (%)",
|
||||
"classPerformanceDesc": "Average scores for the last {count} assignments",
|
||||
"submittedCount": "{submitted}/{total} submitted",
|
||||
"latest": "Latest",
|
||||
"points": "Points"
|
||||
},
|
||||
"schedule": {
|
||||
"noDueDate": "No due date",
|
||||
"scrollForMore": "Scroll for more",
|
||||
"noMoreClasses": "No more classes today",
|
||||
"homeroom": "Homeroom",
|
||||
"room": "Room"
|
||||
},
|
||||
"action": {
|
||||
"review": "Review",
|
||||
"view": "View",
|
||||
"continue": "Continue",
|
||||
"start": "Start",
|
||||
"grade": "Grade"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@
|
||||
"titleLabel": "Lesson Plan Title",
|
||||
"titlePlaceholder": "e.g., Autumn - Lesson 1",
|
||||
"blockCount": "{count} sections",
|
||||
"blankHint": "Start from scratch"
|
||||
"blankHint": "Start from scratch",
|
||||
"names": {
|
||||
"tpl_regular": "Regular Lesson",
|
||||
"tpl_review": "Review Lesson",
|
||||
"tpl_experiment": "Experiment Lesson",
|
||||
"tpl_inquiry": "Inquiry Lesson",
|
||||
"tpl_blank": "Blank Template"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"canvasEmpty": "Canvas is empty",
|
||||
@@ -87,7 +94,9 @@
|
||||
"auto": "Auto",
|
||||
"manual": "Manual save",
|
||||
"revert": "Revert to this version",
|
||||
"revertTitle": "Confirm Revert",
|
||||
"revertConfirm": "Revert to v{versionNo}? A new version will be created.",
|
||||
"revertSuccess": "Reverted to v{versionNo}",
|
||||
"autoLabel": "Auto version"
|
||||
},
|
||||
"knowledgePoint": {
|
||||
@@ -193,6 +202,7 @@
|
||||
"retry": "Retry"
|
||||
},
|
||||
"confirm": {
|
||||
"archive": "Archive this lesson plan?"
|
||||
"archive": "Archive this lesson plan?",
|
||||
"archiveTitle": "Confirm Archive"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,17 @@
|
||||
"grades": "成绩",
|
||||
"attendance": "考勤",
|
||||
"announcements": "通知",
|
||||
"leaveRequest": "请假申请"
|
||||
"leaveRequest": "请假申请",
|
||||
"createAssignment": "新建作业",
|
||||
"grade": "批改",
|
||||
"myClasses": "我的班级",
|
||||
"viewAll": "查看全部",
|
||||
"viewSchedule": "查看课表",
|
||||
"viewAllAssignments": "查看全部作业",
|
||||
"viewAllSubmissions": "查看全部提交",
|
||||
"createNewAssignment": "新建作业",
|
||||
"create": "新建",
|
||||
"createClass": "新建班级"
|
||||
},
|
||||
"todo": {
|
||||
"title": "今日待办",
|
||||
@@ -86,13 +96,26 @@
|
||||
"upcomingAssignments": "即将到期的作业",
|
||||
"grades": "成绩",
|
||||
"myClasses": "我的班级",
|
||||
"gradeTrends": "成绩趋势"
|
||||
"gradeTrends": "成绩趋势",
|
||||
"homework": "作业",
|
||||
"recentSubmissions": "最近提交",
|
||||
"classPerformance": "班级表现",
|
||||
"recentGrades": "最近成绩"
|
||||
},
|
||||
"table": {
|
||||
"name": "姓名",
|
||||
"email": "邮箱",
|
||||
"role": "角色",
|
||||
"created": "创建时间"
|
||||
"created": "创建时间",
|
||||
"student": "学生",
|
||||
"assignment": "作业",
|
||||
"submitted": "提交时间",
|
||||
"action": "操作",
|
||||
"title": "标题",
|
||||
"status": "状态",
|
||||
"due": "截止时间",
|
||||
"score": "分数",
|
||||
"when": "时间"
|
||||
},
|
||||
"empty": {
|
||||
"noUsers": "暂无用户",
|
||||
@@ -105,12 +128,31 @@
|
||||
"noChildrenDesc": "您的账号尚未关联任何学生账号,请联系学校管理员完成绑定。",
|
||||
"noStudent": "未找到学生用户",
|
||||
"noStudentDesc": "请创建学生账号以查看仪表盘。",
|
||||
"contactSupport": "联系客服"
|
||||
"contactSupport": "联系客服",
|
||||
"noClassesYet": "暂无班级",
|
||||
"noClassesDesc": "创建班级以开始管理学生与课表。",
|
||||
"noAssignments": "暂无作业",
|
||||
"noAssignmentsDesc": "创建作业以开始布置任务。",
|
||||
"noAssignmentsStudent": "暂无作业",
|
||||
"noAssignmentsStudentDesc": "当前没有布置给你的作业。",
|
||||
"noClassesToday": "今日无课",
|
||||
"noClassesTodayDesc": "今日课表为空。",
|
||||
"noNewSubmissions": "暂无新提交",
|
||||
"noNewSubmissionsDesc": "全部处理完成!暂无新提交需要批改。",
|
||||
"noData": "暂无数据",
|
||||
"noDataDesc": "发布作业后即可查看班级表现趋势。",
|
||||
"noGradedWork": "暂无已批改作业",
|
||||
"noGradedWorkDesc": "完成并提交作业后即可查看成绩趋势。"
|
||||
},
|
||||
"badge": {
|
||||
"activeSessions": "{count} 个活跃会话",
|
||||
"users": "{count} 位用户",
|
||||
"childrenLinked": "已关联 {count} 个孩子"
|
||||
"childrenLinked": "已关联 {count} 个孩子",
|
||||
"live": "进行中",
|
||||
"late": "已迟交",
|
||||
"inProgress": "进行中",
|
||||
"upNext": "下一节",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "页面加载失败",
|
||||
@@ -121,6 +163,26 @@
|
||||
},
|
||||
"chart": {
|
||||
"newUsers": "新增用户",
|
||||
"newSubmissions": "新增提交"
|
||||
"newSubmissions": "新增提交",
|
||||
"averageScorePercent": "平均分 (%)",
|
||||
"scorePercent": "分数 (%)",
|
||||
"classPerformanceDesc": "最近 {count} 份作业的平均分",
|
||||
"submittedCount": "{submitted}/{total} 已提交",
|
||||
"latest": "最新",
|
||||
"points": "得分"
|
||||
},
|
||||
"schedule": {
|
||||
"noDueDate": "无截止日期",
|
||||
"scrollForMore": "滚动查看更多",
|
||||
"noMoreClasses": "今日无更多课程",
|
||||
"homeroom": "班主任",
|
||||
"room": "教室"
|
||||
},
|
||||
"action": {
|
||||
"review": "查看",
|
||||
"view": "查看",
|
||||
"continue": "继续",
|
||||
"start": "开始",
|
||||
"grade": "批改"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@
|
||||
"titleLabel": "课案标题",
|
||||
"titlePlaceholder": "例如:《秋天》第一课时",
|
||||
"blockCount": "{count} 个环节",
|
||||
"blankHint": "从空白开始"
|
||||
"blankHint": "从空白开始",
|
||||
"names": {
|
||||
"tpl_regular": "常规课",
|
||||
"tpl_review": "复习课",
|
||||
"tpl_experiment": "实验课",
|
||||
"tpl_inquiry": "探究课",
|
||||
"tpl_blank": "空白模板"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"canvasEmpty": "画布为空",
|
||||
@@ -87,7 +94,9 @@
|
||||
"auto": "自动",
|
||||
"manual": "手动保存",
|
||||
"revert": "回退到此版本",
|
||||
"revertTitle": "确认回退",
|
||||
"revertConfirm": "确认回退到 v{versionNo}?将生成新版本。",
|
||||
"revertSuccess": "已回退到 v{versionNo}",
|
||||
"autoLabel": "自动版本"
|
||||
},
|
||||
"knowledgePoint": {
|
||||
@@ -193,6 +202,7 @@
|
||||
"retry": "重试"
|
||||
},
|
||||
"confirm": {
|
||||
"archive": "确认归档此课案?"
|
||||
"archive": "确认归档此课案?",
|
||||
"archiveTitle": "确认归档"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user