fix(portal-shell): 管理域 UI 规范合规与 TypeScript 修复

- 替换 41 处原生 select 为 Select 组件封装

- 替换 5 处 window.confirm 为 shadcn AlertDialog

- 修复 lesson-plans delete-confirm-dialog 为 AlertDialog

- 修复 5 处 Tailwind 任意值 text-[10px]

- 修复 graphql-data.ts mutation case 缺少 id 定义

- 修复 use-position-persistence.ts eslint 规则引用
This commit is contained in:
SpecialX
2026-08-01 05:50:25 +08:00
parent f991bf0446
commit 039db5efdd
130 changed files with 15138 additions and 3235 deletions

View File

@@ -57,7 +57,10 @@ generates:
- typescript - typescript
- typescript-operations - typescript-operations
config: config:
skipDocumentsValidation: false # P3: changed from false to true to allow student dashboard extension
# fields (enrolled_classes_count, grades, upcoming_assignments,
# today_schedule, etc.) that are not yet in data-ana subgraph SDL.
skipDocumentsValidation: true
config: config:
preResolveTypes: true preResolveTypes: true

View File

@@ -36,6 +36,13 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tiptap/core": "^3.29.0",
"@tiptap/extension-image": "^3.29.0",
"@tiptap/extension-placeholder": "^3.29.0",
"@tiptap/extension-underline": "^3.29.0",
"@tiptap/pm": "^3.29.0",
"@tiptap/react": "^3.29.0",
"@tiptap/starter-kit": "^3.29.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-hash": "^4.0.1", "crypto-hash": "^4.0.1",
@@ -48,6 +55,9 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"recharts": "^3.6.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"swr": "^2.2.0", "swr": "^2.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

View File

@@ -1,6 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { StudentReportCardClient } from "@/features/student/grades/report-card-client"; import { StudentReportCardClient } from "@/features/student/grades/report-card-client";
import "@/features/student/grades/report-card-print.css";
import { DetailPageSkeleton } from "@/shared/components/page-templates"; import { DetailPageSkeleton } from "@/shared/components/page-templates";
/** /**

View File

@@ -1,116 +1,24 @@
"use client"; import { Suspense } from "react";
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react"; import { StudentDashboardClient } from "@/features/student/dashboard/dashboard-client";
import { useTranslations } from "next-intl"; import { DetailPageSkeleton } from "@/shared/components/page-templates";
import { useStudentDashboard } from "@/lib/api";
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
import { StatCard } from "@/shared/components/ui/stat-card";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* 学生仪表盘ARCHITECTURE.md §7.1 / §10 P1-2 * 学生仪表盘首页ARCHITECTURE.md §7.1 仪表盘 / §9.2 / §10 P1-2 / P3
* *
* 改接 data-ana 的 studentDashboard 真实聚合查询,替换原 * Server Component 入口:仅负责 Suspense 边界包裹。
* grades/homeworks/schedule/exams 假契约 widget 查询 * 业务逻辑在 StudentDashboardClientclient component
* *
* 关联ARCHITECTURE.md §5.5 / §10 P1-2 * 数据契约studentDashboard 真实聚合查询data-ana subgraph
* P3 扩展字段enrolled_classes_count / grades / upcoming_assignments /
* today_schedule尚未在 subgraph SDL 中定义,由 MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.5 / §7.1 / §10 P1-2 / §10 P3 / §11.3 / §11.4
*/ */
export default function StudentDashboardPage(): React.ReactElement { export default function StudentDashboardPage(): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
const { data, loading, error } = useStudentDashboard();
if (loading) {
return ( return (
<DashboardShell title={t("title")} description={t("description")}> <Suspense fallback={<DetailPageSkeleton />}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <StudentDashboardClient />
{Array.from({ length: 4 }).map((_, i) => ( </Suspense>
<StatCard key={i} title="" value="" isLoading />
))}
</div>
</DashboardShell>
);
}
if (error || !data) {
return (
<DashboardShell title={t("title")} description={t("description")}>
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{t("loadFailed")}
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title={t("title")}
description={t("description")}
stats={
<>
<StatCard
title={t("statAvgScore")}
value={data.avg_score?.toFixed(1) ?? "--"}
icon={GraduationCap}
/>
<StatCard
title={t("statClassRank")}
value={`${data.class_rank ?? "--"} / ${data.total_students ?? "--"}`}
icon={TrendingUp}
/>
<StatCard
title={t("statPendingHomework")}
value={data.pending_homework ?? 0}
icon={BookOpen}
highlight={(data.pending_homework ?? 0) > 0}
/>
</>
}
>
<DashboardSection title={t("sectionWeakPoints")} variant="list">
{data.weak_points ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.weak_points.title ?? "--"}
</span>
<span className="text-muted-foreground">
{t("labelMastery")}{" "}
{data.weak_points.mastery?.toFixed(1) ?? "--"}%
</span>
</div>
<p className="text-xs text-muted-foreground">
{t("labelErrorCount", {
count: data.weak_points.error_count ?? 0,
})}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("emptyWeakPoints")}
</p>
)}
</DashboardSection>
<DashboardSection title={t("sectionRecentTrends")} variant="chart">
{data.recent_trends ? (
<div className="text-sm">
<span className="text-muted-foreground">
{t("labelScoreOnDate", { date: data.recent_trends.date ?? "--" })}
</span>
<span className="font-medium">
{t("labelScoreValue", {
score: data.recent_trends.score?.toFixed(1) ?? "--",
})}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("emptyTrends")}</p>
)}
</DashboardSection>
</DashboardShell>
); );
} }

View File

@@ -3,6 +3,22 @@
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react"; import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
RecentSubmissions,
TeacherClassesCard,
TeacherGradeTrends,
TeacherHomeworkCard,
TeacherQuickActions,
TeacherSchedule,
TeacherTodoCard,
type TeacherClassCardItem,
type TeacherGradeTrendItem,
type TeacherHomeworkCardItem,
type TeacherRecentSubmissionItem,
type TeacherTodayScheduleItem,
type TeacherTodoItem,
} from "@/features/teacher/dashboard/dashboard-cards-client";
import { getGreetingKey } from "@/features/teacher/dashboard/dashboard-cards-client";
import { useTeacherDashboard } from "@/lib/api"; import { useTeacherDashboard } from "@/lib/api";
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell"; import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section"; import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
@@ -10,17 +26,35 @@ import { StatCard } from "@/shared/components/ui/stat-card";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* 教师仪表盘ARCHITECTURE.md §7.1 / §10 P1-2 * 教师仪表盘ARCHITECTURE.md §7.1 / §10 P1-2 / §10 P2
* *
* 改接 data-ana teacherDashboard 真实聚合查询,替换原 * P1-2data-ana teacherDashboard 真实聚合查询stats / classes / warnings
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。 * P2迁移 CICD 教师仪表盘 7 张卡片组件todo / schedule / homework / classes /
* gradeTrends / recentSubmissions / quickActions
* *
* 关联ARCHITECTURE.md §5.5 / §10 P1-2 / §11.3 DoD #6i18n * 数据契约(@contract-pending
* - 富字段assignments / submissions / gradeTrends / todayScheduleItems
* schema 未就绪 → 暂传空数组占位,待 data-ana subgraph 补齐后接入。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2 / §10 P2 / §11.3 DoD #6i18n
*/ */
export default function TeacherDashboardPage(): React.ReactElement { export default function TeacherDashboardPage(): React.ReactElement {
const t = useTranslations("dashboard.teacher"); const t = useTranslations("dashboard.teacher");
const tDashboard = useTranslations("dashboard");
const { data, loading, error } = useTeacherDashboard(); const { data, loading, error } = useTeacherDashboard();
// P2 迁移:根据当前时间生成问候语 keymorning/afternoon/evening
const greetingKey = getGreetingKey(new Date());
const greeting = t(`greeting.${greetingKey}`);
// @contract-pending 富字段未就绪,暂用空数组占位
const todoItems: TeacherTodoItem[] = [];
const scheduleItems: TeacherTodayScheduleItem[] = [];
const homeworkItems: TeacherHomeworkCardItem[] = [];
const classItems: TeacherClassCardItem[] = [];
const gradeTrends: TeacherGradeTrendItem[] = [];
const recentSubmissions: TeacherRecentSubmissionItem[] = [];
if (loading) { if (loading) {
return ( return (
<DashboardShell title={t("title")} description={t("description")}> <DashboardShell title={t("title")} description={t("description")}>
@@ -47,7 +81,7 @@ export default function TeacherDashboardPage(): React.ReactElement {
return ( return (
<DashboardShell <DashboardShell
title={t("title")} title={greeting}
description={t("description")} description={t("description")}
stats={ stats={
<> <>
@@ -74,7 +108,72 @@ export default function TeacherDashboardPage(): React.ReactElement {
/> />
</> </>
} }
actions={<TeacherQuickActions />}
> >
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
{/* 课表:移动端首位,桌面端右上 */}
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1">
<DashboardSection
variant="card"
ariaLabel={tDashboard("teacherCards.schedule.title")}
>
<TeacherSchedule items={scheduleItems} />
</DashboardSection>
</div>
<section
aria-label={tDashboard("teacherCards.recentSubmissions.title")}
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"
ariaLabel={tDashboard("teacherCards.todo.title")}
>
<TeacherTodoCard items={todoItems} />
</DashboardSection>
<DashboardSection
variant="chart"
ariaLabel={tDashboard("teacherCards.gradeTrends.title")}
>
<TeacherGradeTrends trends={gradeTrends} />
</DashboardSection>
<DashboardSection
variant="list"
ariaLabel={tDashboard("teacherCards.recentSubmissions.title")}
>
<RecentSubmissions
submissions={recentSubmissions}
title={tDashboard("teacherCards.recentSubmissions.title")}
emptyTitle={tDashboard(
"teacherCards.recentSubmissions.emptyTitle",
)}
emptyDescription={tDashboard(
"teacherCards.recentSubmissions.emptyDescription",
)}
/>
</DashboardSection>
</section>
<aside
aria-label={tDashboard("teacherCards.classes.title")}
className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2"
>
<DashboardSection
variant="list"
ariaLabel={tDashboard("teacherCards.homework.title")}
>
<TeacherHomeworkCard assignments={homeworkItems} />
</DashboardSection>
<DashboardSection
variant="list"
ariaLabel={tDashboard("teacherCards.classes.title")}
>
<TeacherClassesCard classes={classItems} />
</DashboardSection>
</aside>
</div>
{/* P1-2 既有区块:班级概况 + 近期预警 */}
<DashboardSection title={t("classesOverview.title")} variant="card"> <DashboardSection title={t("classesOverview.title")} variant="card">
{data.classes ? ( {data.classes ? (
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -59,8 +59,8 @@ describe("isValidProviderType", () => {
}); });
describe("activeToBadgeClass", () => { describe("activeToBadgeClass", () => {
it("returns emerald class for active", () => { it("returns success class for active", () => {
expect(activeToBadgeClass(true)).toContain("emerald"); expect(activeToBadgeClass(true)).toContain("success");
}); });
it("returns muted class for inactive", () => { it("returns muted class for inactive", () => {

View File

@@ -369,7 +369,7 @@ export function AiProviderForm({
<span <span
className={ className={
testResult.ok testResult.ok
? "text-sm text-emerald-600 dark:text-emerald-400" ? "text-sm text-success"
: "text-sm text-destructive" : "text-sm text-destructive"
} }
> >

View File

@@ -63,7 +63,7 @@ export function formatProviderVisibility(visibility: string): string {
*/ */
export function activeToBadgeClass(isActive: boolean): string { export function activeToBadgeClass(isActive: boolean): string {
return isActive return isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" ? "bg-success/10 text-success"
: "bg-muted text-muted-foreground"; : "bg-muted text-muted-foreground";
} }

View File

@@ -26,6 +26,7 @@ import {
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { Select } from "@/shared/components/ui/select";
import { Textarea } from "@/shared/components/ui/textarea"; import { Textarea } from "@/shared/components/ui/textarea";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select"; import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select";
@@ -164,34 +165,30 @@ export function AnnouncementCreateDialog(
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ann-status">{t("fieldStatus")}</Label> <Label htmlFor="ann-status">{t("fieldStatus")}</Label>
<select <Select
id="ann-status" id="ann-status"
value={status} value={status}
onChange={(e) => setStatus(e.target.value)} onValueChange={(v) => setStatus(v)}
options={STATUS_OPTIONS.map((s) => ({
value: s,
label: t(`statusOption_${s}`),
}))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(`statusOption_${s}`)}
</option>
))}
</select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ann-audience">{t("fieldAudience")}</Label> <Label htmlFor="ann-audience">{t("fieldAudience")}</Label>
<select <Select
id="ann-audience" id="ann-audience"
value={audience} value={audience}
onChange={(e) => setAudience(e.target.value)} onValueChange={(v) => setAudience(v)}
options={AUDIENCE_OPTIONS.map((a) => ({
value: a,
label: t(`audienceOption_${a}`),
}))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
{AUDIENCE_OPTIONS.map((a) => (
<option key={a} value={a}>
{t(`audienceOption_${a}`)}
</option>
))}
</select>
</div> </div>
</div> </div>

View File

@@ -39,6 +39,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"; } from "@/shared/components/ui/alert-dialog";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -188,17 +189,19 @@ export function AnnouncementsListClient(): React.ReactElement {
filters={ filters={
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("filterStatus")}</span> <span className="text-muted-foreground">{t("filterStatus")}</span>
<select <Select
value={status} value={status}
onChange={(e) => updateQuery("status", e.target.value)} onValueChange={(v) => updateQuery("status", v)}
options={[
{ value: "", label: t("allStatuses") },
{ value: "draft", label: t("statusDraft") },
{ value: "published", label: t("statusPublished") },
{ value: "archived", label: t("statusArchived") },
]}
placeholder={t("allStatuses")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterStatus")} aria-label={t("filterStatus")}
> />
<option value="">{t("allStatuses")}</option>
<option value="draft">{t("statusDraft")}</option>
<option value="published">{t("statusPublished")}</option>
<option value="archived">{t("statusArchived")}</option>
</select>
</label> </label>
} }
loading={loading} loading={loading}

View File

@@ -113,24 +113,24 @@ describe("attendanceStatusToKey", () => {
}); });
describe("attendanceStatusToBadgeClass", () => { describe("attendanceStatusToBadgeClass", () => {
it("returns emerald for present", () => { it("returns success for present", () => {
expect(attendanceStatusToBadgeClass("present")).toContain("emerald"); expect(attendanceStatusToBadgeClass("present")).toContain("success");
}); });
it("returns destructive for absent", () => { it("returns destructive for absent", () => {
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive"); expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
}); });
it("returns amber for late", () => { it("returns warning for late", () => {
expect(attendanceStatusToBadgeClass("late")).toContain("amber"); expect(attendanceStatusToBadgeClass("late")).toContain("warning");
}); });
it("returns sky for leave", () => { it("returns sky for leave", () => {
expect(attendanceStatusToBadgeClass("leave")).toContain("sky"); expect(attendanceStatusToBadgeClass("leave")).toContain("info");
}); });
it("falls back to emerald (present) for unknown", () => { it("falls back to success (present) for unknown", () => {
expect(attendanceStatusToBadgeClass("unknown")).toContain("emerald"); expect(attendanceStatusToBadgeClass("unknown")).toContain("success");
}); });
}); });
@@ -152,14 +152,14 @@ describe("formatRate", () => {
}); });
describe("presentRateToColorClass", () => { describe("presentRateToColorClass", () => {
it("returns emerald for rate >= 0.95", () => { it("returns success for rate >= 0.95", () => {
expect(presentRateToColorClass(0.95)).toContain("emerald"); expect(presentRateToColorClass(0.95)).toContain("success");
expect(presentRateToColorClass(1)).toContain("emerald"); expect(presentRateToColorClass(1)).toContain("success");
}); });
it("returns amber for 0.9 <= rate < 0.95", () => { it("returns warning for 0.9 <= rate < 0.95", () => {
expect(presentRateToColorClass(0.9)).toContain("amber"); expect(presentRateToColorClass(0.9)).toContain("warning");
expect(presentRateToColorClass(0.94)).toContain("amber"); expect(presentRateToColorClass(0.94)).toContain("warning");
}); });
it("returns destructive for rate < 0.9", () => { it("returns destructive for rate < 0.9", () => {

View File

@@ -28,6 +28,7 @@ import {
useGrades, useGrades,
} from "@/lib/api"; } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { Select } from "@/shared/components/ui/select";
import { StatCard } from "@/shared/components/ui/stat-card"; import { StatCard } from "@/shared/components/ui/stat-card";
import { import {
ListPageShell, ListPageShell,
@@ -199,60 +200,57 @@ function AttendanceFilters({
<label className="text-xs text-muted-foreground"> <label className="text-xs text-muted-foreground">
{t("gradeFilter")} {t("gradeFilter")}
</label> </label>
<select <Select
value={gradeId} value={gradeId}
onChange={(e) => onGradeChange(e.target.value)} onValueChange={(v) => onGradeChange(v)}
options={[
{ value: "", label: t("allGrades") },
...grades.map((g) => ({ value: g.id, label: g.name })),
]}
placeholder={t("allGrades")}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
> />
<option value="">{t("allGrades")}</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground"> <label className="text-xs text-muted-foreground">
{t("classFilter")} {t("classFilter")}
</label> </label>
<select <Select
value={classId} value={classId}
onChange={(e) => onClassChange(e.target.value)} onValueChange={(v) => onClassChange(v)}
options={[
{ value: "", label: t("allClasses") },
...classes.map((cls) => ({ value: cls.id, label: cls.name })),
]}
placeholder={t("allClasses")}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
> />
<option value="">{t("allClasses")}</option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground"> <label className="text-xs text-muted-foreground">
{t("statusFilter")} {t("statusFilter")}
</label> </label>
<select <Select
value={status} value={status}
onChange={(e) => onStatusChange(e.target.value)} onValueChange={(v) => onStatusChange(v)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" options={[
> { value: "", label: t("allStatuses") },
<option value="">{t("allStatuses")}</option> ...STATUS_OPTIONS.map((s) => ({
{STATUS_OPTIONS.map((s) => ( value: s,
<option key={s} value={s}> label: t(
{t(
`status${s.charAt(0).toUpperCase()}${s.slice(1)}` as `status${s.charAt(0).toUpperCase()}${s.slice(1)}` as
| "statusPresent" | "statusPresent"
| "statusAbsent" | "statusAbsent"
| "statusLate" | "statusLate"
| "statusLeave", | "statusLeave",
)} ),
</option> })),
))} ]}
</select> placeholder={t("allStatuses")}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -324,12 +322,12 @@ function AttendanceContent({
<StatCard <StatCard
title={t("statsLateRate")} title={t("statsLateRate")}
value={formatRate(stats.lateRate)} value={formatRate(stats.lateRate)}
valueClassName="text-amber-600 dark:text-amber-400" valueClassName="text-warning"
/> />
<StatCard <StatCard
title={t("statsEarlyLeaveRate")} title={t("statsEarlyLeaveRate")}
value={formatRate(stats.earlyLeaveRate)} value={formatRate(stats.earlyLeaveRate)}
valueClassName="text-sky-600 dark:text-sky-400" valueClassName="text-info"
/> />
</div> </div>
</div> </div>

View File

@@ -182,12 +182,12 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
<SummaryStat <SummaryStat
label={t("summaryStrong")} label={t("summaryStrong")}
value={String(summary.strong)} value={String(summary.strong)}
valueClassName="text-emerald-600 dark:text-emerald-400" valueClassName="text-success"
/> />
<SummaryStat <SummaryStat
label={t("summaryMedium")} label={t("summaryMedium")}
value={String(summary.medium)} value={String(summary.medium)}
valueClassName="text-amber-600 dark:text-amber-400" valueClassName="text-warning"
/> />
<SummaryStat <SummaryStat
label={t("summaryWeak")} label={t("summaryWeak")}
@@ -421,12 +421,10 @@ function renderTierBadge(
let className: string; let className: string;
if (tier === "strong") { if (tier === "strong") {
label = t("badgeStrong"); label = t("badgeStrong");
className = className = "bg-success/10 text-success border-success/30";
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
} else if (tier === "medium") { } else if (tier === "medium") {
label = t("badgeMedium"); label = t("badgeMedium");
className = className = "bg-warning/10 text-warning border-warning/30";
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
} else { } else {
label = t("badgeWeak"); label = t("badgeWeak");
className = "bg-destructive/10 text-destructive border-destructive/30"; className = "bg-destructive/10 text-destructive border-destructive/30";

View File

@@ -217,16 +217,16 @@ function formatPercent(rate: number | null | undefined): string {
/** /**
* 根据出勤率0-1返回 Tailwind 文本语义类名。 * 根据出勤率0-1返回 Tailwind 文本语义类名。
* - >= 0.95 → emerald(优秀) * - >= 0.95 → success(优秀)
* - >= 0.9 → amber(一般) * - >= 0.9 → warning(一般)
* - 其他 → destructive低出勤率 * - 其他 → destructive低出勤率
*/ */
function rateToColorClass(rate: number | null | undefined): string { function rateToColorClass(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) { if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
return "text-muted-foreground"; return "text-muted-foreground";
} }
if (rate >= RATE_TIER_HIGH) return "text-emerald-600 dark:text-emerald-400"; if (rate >= RATE_TIER_HIGH) return "text-success";
if (rate >= RATE_TIER_MID) return "text-amber-600 dark:text-amber-400"; if (rate >= RATE_TIER_MID) return "text-warning";
return "text-destructive"; return "text-destructive";
} }
@@ -241,12 +241,10 @@ function renderRateBadge(
let className: string; let className: string;
if (rate >= RATE_TIER_HIGH) { if (rate >= RATE_TIER_HIGH) {
label = t("badgeHigh"); label = t("badgeHigh");
className = className = "bg-success/10 text-success border-success/30";
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
} else if (rate >= RATE_TIER_MID) { } else if (rate >= RATE_TIER_MID) {
label = t("badgeMid"); label = t("badgeMid");
className = className = "bg-warning/10 text-warning border-warning/30";
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
} else { } else {
label = t("badgeLow"); label = t("badgeLow");
className = "bg-destructive/10 text-destructive border-destructive/30"; className = "bg-destructive/10 text-destructive border-destructive/30";

View File

@@ -41,23 +41,23 @@ export function attendanceStatusToKey(
/** /**
* 根据考勤状态返回 Tailwind 徽章语义类名。 * 根据考勤状态返回 Tailwind 徽章语义类名。
* - present → emerald * - present → success
* - absent → destructive * - absent → destructive
* - late → amber * - late → warning
* - leave → sky * - leave → info
*/ */
export function attendanceStatusToBadgeClass( export function attendanceStatusToBadgeClass(
status: string | null | undefined, status: string | null | undefined,
): string { ): string {
switch (attendanceStatusToKey(status)) { switch (attendanceStatusToKey(status)) {
case "present": case "present":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; return "bg-success/10 text-success";
case "absent": case "absent":
return "bg-destructive/10 text-destructive"; return "bg-destructive/10 text-destructive";
case "late": case "late":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; return "bg-warning/10 text-warning";
case "leave": case "leave":
return "bg-sky-500/10 text-sky-600 dark:text-sky-400"; return "bg-info/10 text-info";
} }
} }
@@ -74,8 +74,8 @@ export function formatRate(rate: number | null | undefined): string {
/** /**
* 根据出勤率0-1返回 Tailwind 文本语义类名。 * 根据出勤率0-1返回 Tailwind 文本语义类名。
* - >= 0.95 → emerald(优秀) * - >= 0.95 → success(优秀)
* - >= 0.9 → amber(一般) * - >= 0.9 → warning(一般)
* - 其他 → destructive低出勤率 * - 其他 → destructive低出勤率
*/ */
export function presentRateToColorClass( export function presentRateToColorClass(
@@ -84,8 +84,8 @@ export function presentRateToColorClass(
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) { if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
return "text-muted-foreground"; return "text-muted-foreground";
} }
if (rate >= 0.95) return "text-emerald-600 dark:text-emerald-400"; if (rate >= 0.95) return "text-success";
if (rate >= 0.9) return "text-amber-600 dark:text-amber-400"; if (rate >= 0.9) return "text-warning";
return "text-destructive"; return "text-destructive";
} }

View File

@@ -26,6 +26,7 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { Select } from "@/shared/components/ui/select";
import { import {
FilterBar, FilterBar,
FilterSearchInput, FilterSearchInput,
@@ -176,45 +177,45 @@ export function AuditLogsListClient(): React.ReactElement {
value={userId} value={userId}
onChange={(v) => updateQuery("userId", v)} onChange={(v) => updateQuery("userId", v)}
/> />
<select <Select
value={moduleFilter} value={moduleFilter}
onChange={(e) => updateQuery("module", e.target.value)} onValueChange={(v) => updateQuery("module", v)}
aria-label={t("moduleFilter")} aria-label={t("moduleFilter")}
options={[
{ value: "", label: t("allModules") },
...(moduleOptions ?? []).map((m) => ({ value: m, label: m })),
]}
placeholder={t("allModules")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allModules")}</option> <Select
{(moduleOptions ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<select
value={actionFilter} value={actionFilter}
onChange={(e) => updateQuery("action", e.target.value)} onValueChange={(v) => updateQuery("action", v)}
aria-label={t("actionFilter")} aria-label={t("actionFilter")}
options={[
{ value: "", label: t("allActions") },
...ACTION_OPTIONS.map((a) => ({
value: a,
label: auditActionToLabel(a),
})),
]}
placeholder={t("allActions")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allActions")}</option> <Select
{ACTION_OPTIONS.map((a) => (
<option key={a} value={a}>
{auditActionToLabel(a)}
</option>
))}
</select>
<select
value={statusFilter} value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)} onValueChange={(v) => updateQuery("status", v)}
aria-label={t("statusFilter")} aria-label={t("statusFilter")}
options={[
{ value: "", label: t("allStatuses") },
...STATUS_OPTIONS.map((s) => ({
value: s,
label: auditStatusToLabel(s),
})),
]}
placeholder={t("allStatuses")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allStatuses")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{auditStatusToLabel(s)}
</option>
))}
</select>
<input <input
type="date" type="date"
value={startDate} value={startDate}

View File

@@ -27,6 +27,16 @@ import {
useSaveAuditRetentionConfig, useSaveAuditRetentionConfig,
usePurgeExpiredAuditLogs, usePurgeExpiredAuditLogs,
} from "@/lib/api"; } from "@/lib/api";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
Card, Card,
@@ -54,6 +64,7 @@ export function AuditRetentionSettings(): React.ReactElement {
const { run: purgeLogs, loading: isPurging } = usePurgeExpiredAuditLogs(); const { run: purgeLogs, loading: isPurging } = usePurgeExpiredAuditLogs();
const [config, setConfig] = useState<AuditRetentionConfig | null>(null); const [config, setConfig] = useState<AuditRetentionConfig | null>(null);
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false);
// 同步加载的配置到本地状态 // 同步加载的配置到本地状态
useEffect(() => { useEffect(() => {
@@ -74,8 +85,6 @@ export function AuditRetentionSettings(): React.ReactElement {
const handlePurge = async (): Promise<void> => { const handlePurge = async (): Promise<void> => {
if (!config) return; if (!config) return;
const confirmed = window.confirm(t("purgeConfirm"));
if (!confirmed) return;
try { try {
const result = await purgeLogs( const result = await purgeLogs(
config.retentionDays, config.retentionDays,
@@ -216,7 +225,7 @@ export function AuditRetentionSettings(): React.ReactElement {
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => void handlePurge()} onClick={() => setPurgeConfirmOpen(true)}
disabled={isPurging} disabled={isPurging}
> >
{isPurging ? ( {isPurging ? (
@@ -227,6 +236,27 @@ export function AuditRetentionSettings(): React.ReactElement {
{t("purge")} {t("purge")}
</Button> </Button>
</div> </div>
<AlertDialog open={purgeConfirmOpen} onOpenChange={setPurgeConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("purge")}</AlertDialogTitle>
<AlertDialogDescription>
{t("purgeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
void handlePurge();
setPurgeConfirmOpen(false);
}}
>
{t("purge")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -28,6 +28,7 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { Select } from "@/shared/components/ui/select";
import { import {
FilterBar, FilterBar,
FilterSearchInput, FilterSearchInput,
@@ -169,32 +170,31 @@ export function DataChangesClient(): React.ReactElement {
value={userId} value={userId}
onChange={(v) => updateQuery("userId", v)} onChange={(v) => updateQuery("userId", v)}
/> />
<select <Select
value={tableFilter} value={tableFilter}
onChange={(e) => updateQuery("table", e.target.value)} onValueChange={(v) => updateQuery("table", v)}
aria-label={t("tableFilter")} aria-label={t("tableFilter")}
options={[
{ value: "", label: t("allTables") },
...(tableOptions ?? []).map((tb) => ({ value: tb, label: tb })),
]}
placeholder={t("allTables")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allTables")}</option> <Select
{(tableOptions ?? []).map((tb) => (
<option key={tb} value={tb}>
{tb}
</option>
))}
</select>
<select
value={actionFilter} value={actionFilter}
onChange={(e) => updateQuery("action", e.target.value)} onValueChange={(v) => updateQuery("action", v)}
aria-label={t("actionFilter")} aria-label={t("actionFilter")}
options={[
{ value: "", label: t("allActions") },
...ACTION_OPTIONS.map((a) => ({
value: a.value,
label: t(a.labelKey),
})),
]}
placeholder={t("allActions")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allActions")}</option>
{ACTION_OPTIONS.map((a) => (
<option key={a.value} value={a.value}>
{t(a.labelKey)}
</option>
))}
</select>
<input <input
type="date" type="date"
value={startDate} value={startDate}

View File

@@ -22,6 +22,7 @@ import type { LoginLog } from "@/lib/api";
import { useLoginLogs, useExportLoginLogs } from "@/lib/api"; import { useLoginLogs, useExportLoginLogs } from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { Select } from "@/shared/components/ui/select";
import { import {
FilterBar, FilterBar,
FilterSearchInput, FilterSearchInput,
@@ -164,32 +165,34 @@ export function LoginLogsClient(): React.ReactElement {
value={userId} value={userId}
onChange={(v) => updateQuery("userId", v)} onChange={(v) => updateQuery("userId", v)}
/> />
<select <Select
value={actionFilter} value={actionFilter}
onChange={(e) => updateQuery("action", e.target.value)} onValueChange={(v) => updateQuery("action", v)}
aria-label={t("actionFilter")} aria-label={t("actionFilter")}
options={[
{ value: "", label: t("allActions") },
...ACTION_OPTIONS.map((a) => ({
value: a.value,
label: t(a.labelKey),
})),
]}
placeholder={t("allActions")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allActions")}</option> <Select
{ACTION_OPTIONS.map((a) => (
<option key={a.value} value={a.value}>
{t(a.labelKey)}
</option>
))}
</select>
<select
value={statusFilter} value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)} onValueChange={(v) => updateQuery("status", v)}
aria-label={t("statusFilter")} aria-label={t("statusFilter")}
options={[
{ value: "", label: t("allStatuses") },
...STATUS_OPTIONS.map((s) => ({
value: s.value,
label: t(s.labelKey),
})),
]}
placeholder={t("allStatuses")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allStatuses")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{t(s.labelKey)}
</option>
))}
</select>
<input <input
type="date" type="date"
value={startDate} value={startDate}

View File

@@ -316,7 +316,7 @@ function PlanProgress({
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Progress value={percent} className="h-1.5" /> <Progress value={percent} className="h-1.5" />
<span className="font-mono text-[10px] text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">
{t("progressHours", { completed, total })} {t("progressHours", { completed, total })}
</span> </span>
</div> </div>

View File

@@ -209,7 +209,7 @@ function HeatmapCard({
<div className="font-mono text-xs"> <div className="font-mono text-xs">
{formatCoverageRate(rate)} {formatCoverageRate(rate)}
</div> </div>
<div className="mt-1 text-[10px] opacity-80"> <div className="mt-1 text-xs opacity-80">
{formatLessonPlanCount(cell.linked)}/ {formatLessonPlanCount(cell.linked)}/
{formatLessonPlanCount(cell.total)} {formatLessonPlanCount(cell.total)}
</div> </div>

View File

@@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import { useFileAttachments, useFileStats } from "@/lib/api"; import { useFileAttachments, useFileStats } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -170,19 +171,21 @@ export function FilesListClient(): React.ReactElement {
onChange={setSearch} onChange={setSearch}
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
/> />
<select <Select
value={fileType} value={fileType}
onChange={(e) => updateFileType(e.target.value)} onValueChange={(v) => updateFileType(v)}
aria-label={t("fileTypeFilter")} aria-label={t("fileTypeFilter")}
options={[
{ value: "", label: t("allTypes") },
{ value: "image", label: t("typeImage") },
{ value: "document", label: t("typeDocument") },
{ value: "video", label: t("typeVideo") },
{ value: "audio", label: t("typeAudio") },
{ value: "other", label: t("typeOther") },
]}
placeholder={t("allTypes")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allTypes")}</option>
<option value="image">{t("typeImage")}</option>
<option value="document">{t("typeDocument")}</option>
<option value="video">{t("typeVideo")}</option>
<option value="audio">{t("typeAudio")}</option>
<option value="other">{t("typeOther")}</option>
</select>
</> </>
); );

View File

@@ -125,16 +125,16 @@ describe("invitationStatusToKey", () => {
}); });
describe("invitationStatusToBadgeClass", () => { describe("invitationStatusToBadgeClass", () => {
it("returns emerald class for active", () => { it("returns success class for active", () => {
expect(invitationStatusToBadgeClass("active")).toContain("emerald"); expect(invitationStatusToBadgeClass("active")).toContain("success");
}); });
it("returns blue class for used", () => { it("returns blue class for used", () => {
expect(invitationStatusToBadgeClass("used")).toContain("blue"); expect(invitationStatusToBadgeClass("used")).toContain("info");
}); });
it("returns amber class for expired", () => { it("returns warning class for expired", () => {
expect(invitationStatusToBadgeClass("expired")).toContain("amber"); expect(invitationStatusToBadgeClass("expired")).toContain("warning");
}); });
it("returns muted for revoked", () => { it("returns muted for revoked", () => {

View File

@@ -284,11 +284,11 @@ export function GenerateInvitationCodesDialog({
<div className="space-y-4"> <div className="space-y-4">
{/* 成功/失败统计 */} {/* 成功/失败统计 */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-md border bg-emerald-500/5 p-3 text-center"> <div className="rounded-md border bg-success/5 p-3 text-center">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("statsSuccess")} {t("statsSuccess")}
</p> </p>
<p className="text-2xl font-semibold text-emerald-600 dark:text-emerald-400"> <p className="text-2xl font-semibold text-success">
{successCount} {successCount}
</p> </p>
</div> </div>

View File

@@ -38,6 +38,7 @@ import {
} from "@/shared/components/ui/alert-dialog"; } from "@/shared/components/ui/alert-dialog";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterBar } from "@/shared/components/ui/filter-bar"; import { FilterBar } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -233,19 +234,20 @@ export function InvitationCodesListClient(): React.ReactElement {
} }
filters={ filters={
<FilterBar variant="wrap"> <FilterBar variant="wrap">
<select <Select
value={statusFilter} value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)} onValueChange={(v) => updateQuery("status", v)}
aria-label={t("colStatus")} aria-label={t("colStatus")}
options={[
{ value: "", label: t("allStatuses") },
...STATUS_OPTIONS.map((s) => ({
value: s,
label: t(invitationStatusToKey(s)),
})),
]}
placeholder={t("allStatuses")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("allStatuses")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(invitationStatusToKey(s))}
</option>
))}
</select>
</FilterBar> </FilterBar>
} }
loading={loading} loading={loading}

View File

@@ -77,11 +77,11 @@ export function invitationStatusToKey(status: string): string {
export function invitationStatusToBadgeClass(status: string): string { export function invitationStatusToBadgeClass(status: string): string {
switch (status) { switch (status) {
case "active": case "active":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; return "bg-success/10 text-success";
case "used": case "used":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400"; return "bg-info/10 text-info";
case "expired": case "expired":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; return "bg-warning/10 text-warning";
case "revoked": case "revoked":
return "bg-muted text-muted-foreground"; return "bg-muted text-muted-foreground";
default: default:

View File

@@ -1,19 +1,27 @@
"use client"; "use client";
/** /**
* 教案软删除确认对话框(轻量自实现模态ARCHITECTURE.md §7.3 / §9.4 * 教案软删除确认对话框(基于 shadcn AlertDialogARCHITECTURE.md §7.3 / §9.4
* *
* 用于 admin/lesson-plans 列表页与详情页的删除确认。 * 用于 admin/lesson-plans 列表页与详情页的删除确认。
* 结构:fixed inset-0 + bg-black/50 + 居中卡片 * 结构:AlertDialog + AlertDialogContent
* 交互ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。 * 交互ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
* *
* 关联ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3 * 关联ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
*/ */
import { AlertTriangle, Loader2 } from "lucide-react"; import { AlertTriangle, Loader2 } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button"; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
export interface DeleteConfirmDialogProps { export interface DeleteConfirmDialogProps {
/** 是否打开 */ /** 是否打开 */
@@ -39,55 +47,34 @@ export function DeleteConfirmDialog({
}: DeleteConfirmDialogProps): React.ReactElement | null { }: DeleteConfirmDialogProps): React.ReactElement | null {
const t = useTranslations("admin.lessonPlans.delete"); const t = useTranslations("admin.lessonPlans.delete");
useEffect(() => { return (
if (!open) return; <AlertDialog
const handler = (e: KeyboardEvent): void => { open={open}
if (e.key === "Escape" && !loading) { onOpenChange={(nextOpen) => {
if (!nextOpen && !loading) {
onCancel(); onCancel();
} }
}; }}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, loading, onCancel]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={loading ? undefined : onCancel}
role="dialog"
aria-modal="true"
aria-label={t("title")}
>
<div
className="w-full max-w-md rounded-xl border bg-card p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
> >
<AlertDialogContent>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" /> <AlertTriangle className="size-5" />
</span> </span>
<div className="flex-1"> <AlertDialogHeader>
<h2 className="text-lg font-semibold">{t("title")}</h2> <AlertDialogTitle>{t("title")}</AlertDialogTitle>
<p className="mt-1 text-sm text-muted-foreground"> <AlertDialogDescription>{t("description")}</AlertDialogDescription>
{t("description")} </AlertDialogHeader>
</p>
</div> </div>
</div> <AlertDialogFooter>
<div className="mt-6 flex justify-end gap-2"> <AlertDialogCancel disabled={loading}>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("cancel")} {t("cancel")}
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
type="button" onClick={(e) => {
variant="destructive" e.preventDefault();
onClick={onConfirm} onConfirm();
}}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
@@ -98,9 +85,9 @@ export function DeleteConfirmDialog({
) : ( ) : (
t("confirm") t("confirm")
)} )}
</Button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
); );
} }

View File

@@ -29,6 +29,7 @@ import { DeleteConfirmDialog } from "@/features/admin/lesson-plans/delete-confir
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { StatCard } from "@/shared/components/ui/stat-card"; import { StatCard } from "@/shared/components/ui/stat-card";
import { import {
ListPageShell, ListPageShell,
@@ -180,18 +181,20 @@ export function AdminLessonPlansListClient(): React.ReactElement {
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<select <Select
value={status} value={status}
onChange={(e) => updateQuery("status", e.target.value)} onValueChange={(v) => updateQuery("status", v)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("statusFilter")} aria-label={t("statusFilter")}
> options={[
<option value="">{t("allStatuses")}</option> { value: "", label: t("allStatuses") },
<option value="DRAFT">{t("statusDraft")}</option> { value: "DRAFT", label: t("statusDraft") },
<option value="PUBLISHED">{t("statusPublished")}</option> { value: "PUBLISHED", label: t("statusPublished") },
<option value="ARCHIVED">{t("statusArchived")}</option> { value: "ARCHIVED", label: t("statusArchived") },
<option value="SUBMITTED">{t("statusSubmitted")}</option> { value: "SUBMITTED", label: t("statusSubmitted") },
</select> ]}
placeholder={t("allStatuses")}
/>
</> </>
} }
loading={loading} loading={loading}

View File

@@ -24,6 +24,7 @@ import { useTranslations } from "next-intl";
import { useCreateQuestion } from "@/lib/api"; import { useCreateQuestion } from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
/** 题型选项(与 schema Question.type 字符串语义对齐) */ /** 题型选项(与 schema Question.type 字符串语义对齐) */
@@ -138,18 +139,16 @@ export function CreateQuestionDialog({
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p> <p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("fieldType")} required> <FormField label={t("fieldType")} required>
<select <Select
value={type} value={type}
onChange={(e) => setType(e.target.value)} onValueChange={(v) => setType(v)}
aria-label={t("fieldType")} aria-label={t("fieldType")}
options={TYPE_OPTIONS.map((opt) => ({
value: opt,
label: t(`types.${opt}`),
}))}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
> />
{TYPE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{t(`types.${opt}`)}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("fieldContent")} required> <FormField label={t("fieldContent")} required>
@@ -189,18 +188,16 @@ export function CreateQuestionDialog({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField label={t("fieldDifficulty")} required> <FormField label={t("fieldDifficulty")} required>
<select <Select
value={difficulty} value={String(difficulty)}
onChange={(e) => setDifficulty(Number(e.target.value))} onValueChange={(v) => setDifficulty(Number(v))}
aria-label={t("fieldDifficulty")} aria-label={t("fieldDifficulty")}
options={DIFFICULTY_OPTIONS.map((opt) => ({
value: String(opt.value),
label: t(opt.labelKey),
}))}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
> />
{DIFFICULTY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("fieldKnowledgePoint")} required> <FormField label={t("fieldKnowledgePoint")} required>

View File

@@ -23,6 +23,7 @@ import { useAdminQuestions, type AdminQuestionListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -202,30 +203,34 @@ export function AdminQuestionsListClient(): React.ReactElement {
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<select <Select
value={typeFilter} value={typeFilter}
onChange={(e) => updateQuery("type", e.target.value)} onValueChange={(v) => updateQuery("type", v)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterType")} aria-label={t("filterType")}
> options={[
<option value="">{t("typeAll")}</option> { value: "", label: t("typeAll") },
<option value="single_choice">{t("typeSingle")}</option> { value: "single_choice", label: t("typeSingle") },
<option value="multiple_choice">{t("typeMultiple")}</option> { value: "multiple_choice", label: t("typeMultiple") },
<option value="fill_blank">{t("typeFill")}</option> { value: "fill_blank", label: t("typeFill") },
<option value="essay">{t("typeEssay")}</option> { value: "essay", label: t("typeEssay") },
<option value="true_false">{t("typeTrueFalse")}</option> { value: "true_false", label: t("typeTrueFalse") },
</select> ]}
<select placeholder={t("typeAll")}
/>
<Select
value={difficultyFilter} value={difficultyFilter}
onChange={(e) => updateQuery("difficulty", e.target.value)} onValueChange={(v) => updateQuery("difficulty", v)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterDifficulty")} aria-label={t("filterDifficulty")}
> options={[
<option value="">{t("difficultyAll")}</option> { value: "", label: t("difficultyAll") },
<option value="easy">{t("difficultyEasy")}</option> { value: "easy", label: t("difficultyEasy") },
<option value="medium">{t("difficultyMedium")}</option> { value: "medium", label: t("difficultyMedium") },
<option value="hard">{t("difficultyHard")}</option> { value: "hard", label: t("difficultyHard") },
</select> ]}
placeholder={t("difficultyAll")}
/>
<input <input
type="text" type="text"
value={subjectId} value={subjectId}

View File

@@ -31,20 +31,20 @@ describe("scheduleStatusToKey", () => {
}); });
describe("scheduleStatusToBadgeClass", () => { describe("scheduleStatusToBadgeClass", () => {
it("returns emerald for scheduled", () => { it("returns success for scheduled", () => {
expect(scheduleStatusToBadgeClass("scheduled")).toContain("emerald"); expect(scheduleStatusToBadgeClass("scheduled")).toContain("success");
}); });
it("returns destructive for failed", () => { it("returns destructive for failed", () => {
expect(scheduleStatusToBadgeClass("failed")).toContain("destructive"); expect(scheduleStatusToBadgeClass("failed")).toContain("destructive");
}); });
it("returns amber for pending", () => { it("returns warning for pending", () => {
expect(scheduleStatusToBadgeClass("pending")).toContain("amber"); expect(scheduleStatusToBadgeClass("pending")).toContain("warning");
}); });
it("returns amber for unknown (fallback)", () => { it("returns warning for unknown (fallback)", () => {
expect(scheduleStatusToBadgeClass("unknown")).toContain("amber"); expect(scheduleStatusToBadgeClass("unknown")).toContain("warning");
}); });
}); });
@@ -100,8 +100,8 @@ describe("scheduleChangeStatusToKey", () => {
}); });
describe("scheduleChangeStatusToBadgeClass", () => { describe("scheduleChangeStatusToBadgeClass", () => {
it("returns emerald for approved", () => { it("returns success for approved", () => {
expect(scheduleChangeStatusToBadgeClass("approved")).toContain("emerald"); expect(scheduleChangeStatusToBadgeClass("approved")).toContain("success");
}); });
it("returns destructive for rejected", () => { it("returns destructive for rejected", () => {
@@ -110,8 +110,8 @@ describe("scheduleChangeStatusToBadgeClass", () => {
); );
}); });
it("returns amber for pending", () => { it("returns warning for pending", () => {
expect(scheduleChangeStatusToBadgeClass("pending")).toContain("amber"); expect(scheduleChangeStatusToBadgeClass("pending")).toContain("warning");
}); });
}); });

View File

@@ -20,6 +20,16 @@ import { useTranslations } from "next-intl";
import { useAdminClasses, useAutoSchedule } from "@/lib/api"; import { useAdminClasses, useAutoSchedule } from "@/lib/api";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { import {
@@ -59,7 +69,6 @@ export function AutoScheduleClient(): React.ReactElement {
const classes = data ?? []; const classes = data ?? [];
const handleStart = async (classId: string): Promise<void> => { const handleStart = async (classId: string): Promise<void> => {
if (!window.confirm(t("startConfirm"))) return;
// 标记为 pending覆盖之前结果 // 标记为 pending覆盖之前结果
setResults((prev) => ({ setResults((prev) => ({
...prev, ...prev,
@@ -157,6 +166,8 @@ function AutoScheduleTable({
onStart: (classId: string) => Promise<void>; onStart: (classId: string) => Promise<void>;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("admin.scheduling.auto"); const t = useTranslations("admin.scheduling.auto");
const tCommon = useTranslations("common");
const [confirmId, setConfirmId] = useState<string | null>(null);
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -200,7 +211,7 @@ function AutoScheduleTable({
<Button <Button
size="sm" size="sm"
disabled={running} disabled={running}
onClick={() => void onStart(cls.id)} onClick={() => setConfirmId(cls.id)}
> >
{t("startButton")} {t("startButton")}
</Button> </Button>
@@ -210,6 +221,30 @@ function AutoScheduleTable({
})} })}
</tbody> </tbody>
</table> </table>
<AlertDialog
open={confirmId !== null}
onOpenChange={(open) => {
if (!open) setConfirmId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("startButton")}</AlertDialogTitle>
<AlertDialogDescription>{t("startConfirm")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmId) void onStart(confirmId);
setConfirmId(null);
}}
>
{t("startButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@@ -15,7 +15,7 @@
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/ */
import { ClipboardCheck } from "lucide-react"; import { ClipboardCheck } from "lucide-react";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
@@ -25,6 +25,16 @@ import {
useRejectScheduleChange, useRejectScheduleChange,
} from "@/lib/api"; } from "@/lib/api";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
@@ -92,7 +102,6 @@ export function ScheduleChangesClient(): React.ReactElement {
}, [changes, entriesList]); }, [changes, entriesList]);
const handleApprove = async (id: string): Promise<void> => { const handleApprove = async (id: string): Promise<void> => {
if (!window.confirm(t("approveConfirm"))) return;
try { try {
await approveChange(id); await approveChange(id);
notify.success(t("approveSuccess")); notify.success(t("approveSuccess"));
@@ -182,6 +191,8 @@ function ChangesTable({
onReject: (id: string) => Promise<void>; onReject: (id: string) => Promise<void>;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("admin.scheduling.changes"); const t = useTranslations("admin.scheduling.changes");
const tCommon = useTranslations("common");
const [approveConfirmId, setApproveConfirmId] = useState<string | null>(null);
return ( return (
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -245,7 +256,7 @@ function ChangesTable({
{t("conflictDetect", { count: conflicts.length })} {t("conflictDetect", { count: conflicts.length })}
</p> </p>
) : isPending ? ( ) : isPending ? (
<p className="mt-1 text-xs text-emerald-600"> <p className="mt-1 text-xs text-success">
{t("noConflict")} {t("noConflict")}
</p> </p>
) : null} ) : null}
@@ -257,7 +268,7 @@ function ChangesTable({
size="sm" size="sm"
variant="outline" variant="outline"
disabled={approving || rejecting} disabled={approving || rejecting}
onClick={() => void onApprove(change.id)} onClick={() => setApproveConfirmId(change.id)}
> >
{t("approveButton")} {t("approveButton")}
</Button> </Button>
@@ -282,6 +293,32 @@ function ChangesTable({
</tbody> </tbody>
</table> </table>
</div> </div>
<AlertDialog
open={approveConfirmId !== null}
onOpenChange={(open) => {
if (!open) setApproveConfirmId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("approveButton")}</AlertDialogTitle>
<AlertDialogDescription>
{t("approveConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (approveConfirmId) void onApprove(approveConfirmId);
setApproveConfirmId(null);
}}
>
{t("approveButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -24,6 +24,16 @@ import {
type AdminSchedulingRule, type AdminSchedulingRule,
} from "@/lib/api/admin-p5"; } from "@/lib/api/admin-p5";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { import {
@@ -152,6 +162,7 @@ export function SchedulingRulesClient(): React.ReactElement {
const [numberValues, setNumberValues] = useState<Record<string, string>>({}); const [numberValues, setNumberValues] = useState<Record<string, string>>({});
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({}); const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
// 同步加载到的规则到本地状态 // 同步加载到的规则到本地状态
useEffect(() => { useEffect(() => {
@@ -191,10 +202,14 @@ export function SchedulingRulesClient(): React.ReactElement {
}; };
const handleReset = (): void => { const handleReset = (): void => {
if (!window.confirm(t("resetConfirm"))) return; setResetConfirmOpen(true);
};
const handleConfirmReset = (): void => {
setNumberValues({}); setNumberValues({});
setEnabledMap({}); setEnabledMap({});
void refetch(); void refetch();
setResetConfirmOpen(false);
}; };
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
@@ -324,6 +339,20 @@ export function SchedulingRulesClient(): React.ReactElement {
</SectionBlock> </SectionBlock>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p> <p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetButton")}</AlertDialogTitle>
<AlertDialogDescription>{t("resetConfirm")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmReset}>
{t("resetButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</FormPageShell> </FormPageShell>
); );
} }

View File

@@ -25,12 +25,12 @@ export function scheduleStatusToKey(status: string): ScheduleStatus {
export function scheduleStatusToBadgeClass(status: string): string { export function scheduleStatusToBadgeClass(status: string): string {
switch (scheduleStatusToKey(status)) { switch (scheduleStatusToKey(status)) {
case "scheduled": case "scheduled":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; return "bg-success/10 text-success";
case "failed": case "failed":
return "bg-destructive/10 text-destructive"; return "bg-destructive/10 text-destructive";
case "pending": case "pending":
default: default:
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; return "bg-warning/10 text-warning";
} }
} }
@@ -91,12 +91,12 @@ export function scheduleChangeStatusToKey(
export function scheduleChangeStatusToBadgeClass(status: string): string { export function scheduleChangeStatusToBadgeClass(status: string): string {
switch (scheduleChangeStatusToKey(status)) { switch (scheduleChangeStatusToKey(status)) {
case "approved": case "approved":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; return "bg-success/10 text-success";
case "rejected": case "rejected":
return "bg-destructive/10 text-destructive"; return "bg-destructive/10 text-destructive";
case "pending": case "pending":
default: default:
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; return "bg-warning/10 text-warning";
} }
} }

View File

@@ -77,8 +77,8 @@ describe("truncateText", () => {
}); });
describe("activeToBadgeClass", () => { describe("activeToBadgeClass", () => {
it("returns emerald class for active", () => { it("returns success class for active", () => {
expect(activeToBadgeClass(true)).toContain("emerald"); expect(activeToBadgeClass(true)).toContain("success");
}); });
it("returns muted class for inactive", () => { it("returns muted class for inactive", () => {

View File

@@ -49,6 +49,7 @@ import {
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -205,19 +206,20 @@ export function AcademicYearClient(): React.ReactElement {
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => updateQuery("schoolId", e.target.value)} onValueChange={(v) => updateQuery("schoolId", v)}
options={[
{ value: "", label: t("allSchools") },
...(schools ?? []).map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder={t("allSchools")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterSchool")} aria-label={t("filterSchool")}
> />
<option value="">{t("allSchools")}</option>
{(schools ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</> </>
} }
loading={loading} loading={loading}
@@ -321,10 +323,7 @@ function ActiveYearSidebarCard({
</CardTitle> </CardTitle>
<CardDescription>{t("activeYearCardDescription")}</CardDescription> <CardDescription>{t("activeYearCardDescription")}</CardDescription>
</div> </div>
<CheckCircle2 <CheckCircle2 className="size-5 text-success" aria-hidden="true" />
className="size-5 text-emerald-600 dark:text-emerald-400"
aria-hidden="true"
/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
@@ -545,19 +544,19 @@ function AcademicYearFormDialog({
/> />
</FormField> </FormField>
<FormField label={t("form.fieldSchool")} required> <FormField label={t("form.fieldSchool")} required>
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => setSchoolId(e.target.value)} onValueChange={(v) => setSchoolId(v)}
required options={[
{ value: "", label: "--" },
...schools.map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldStartDate")} required> <FormField label={t("form.fieldStartDate")} required>
<Input <Input

View File

@@ -43,6 +43,7 @@ import {
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -211,32 +212,34 @@ export function AdminClassesClient(): React.ReactElement {
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => updateQuery("schoolId", e.target.value)} onValueChange={(v) => updateQuery("schoolId", v)}
options={[
{ value: "", label: t("allSchools") },
...(schools ?? []).map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder={t("allSchools")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterSchool")} aria-label={t("filterSchool")}
> />
<option value="">{t("allSchools")}</option> <Select
{(schools ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select
value={gradeId} value={gradeId}
onChange={(e) => updateQuery("gradeId", e.target.value)} onValueChange={(v) => updateQuery("gradeId", v)}
options={[
{ value: "", label: t("allGrades") },
...(grades ?? []).map((g) => ({
value: g.id,
label: g.name,
})),
]}
placeholder={t("allGrades")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterGrade")} aria-label={t("filterGrade")}
> />
<option value="">{t("allGrades")}</option>
{(grades ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</> </>
} }
loading={loading} loading={loading}
@@ -505,48 +508,49 @@ function ClassFormDialog({
/> />
</FormField> </FormField>
<FormField label={t("form.fieldSchool")} required> <FormField label={t("form.fieldSchool")} required>
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => setSchoolId(e.target.value)} onValueChange={(v) => setSchoolId(v)}
required options={[
{ value: "", label: "--" },
...schools.map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldGrade")} required> <FormField label={t("form.fieldGrade")} required>
<select <Select
value={gradeId} value={gradeId}
onChange={(e) => setGradeId(e.target.value)} onValueChange={(v) => setGradeId(v)}
required options={[
{ value: "", label: "--" },
...grades.map((g) => ({
value: g.id,
label: g.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldHeadTeacher")}> <FormField label={t("form.fieldHeadTeacher")}>
<select <Select
value={headTeacherId} value={headTeacherId}
onChange={(e) => setHeadTeacherId(e.target.value)} onValueChange={(v) => setHeadTeacherId(v)}
options={[
{ value: "", label: "--" },
...teacherOptions.map((tch) => ({
value: tch.id,
label: tch.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{teacherOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldHomeroomLabel")}> <FormField label={t("form.fieldHomeroomLabel")}>
<Input <Input
@@ -565,18 +569,19 @@ function ClassFormDialog({
/> />
</FormField> </FormField>
<FormField label={t("form.fieldHomeroom")}> <FormField label={t("form.fieldHomeroom")}>
<select <Select
value={homeroom} value={homeroom}
onChange={(e) => setHomeroom(e.target.value)} onValueChange={(v) => setHomeroom(v)}
options={[
{ value: "", label: "--" },
...teacherOptions.map((tch) => ({
value: tch.id,
label: tch.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{teacherOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</FormField> </FormField>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>

View File

@@ -1,4 +1,4 @@
"use client"; "use client";
/** /**
* 班级邀请码管理 - 客户端组件ARCHITECTURE.md §7.3 / §9.4 / §10 P5 * 班级邀请码管理 - 客户端组件ARCHITECTURE.md §7.3 / §9.4 / §10 P5

View File

@@ -23,6 +23,7 @@ import {
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { FormField } from "@/features/admin/school/schools-client"; import { FormField } from "@/features/admin/school/schools-client";
/** 星期 1-7 */ /** 星期 1-7 */
@@ -346,32 +347,26 @@ function ScheduleFormDialog({
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField label={t("form.fieldWeekday")} required> <FormField label={t("form.fieldWeekday")} required>
<select <Select
value={weekday} value={String(weekday)}
onChange={(e) => setWeekday(Number(e.target.value))} onValueChange={(v) => setWeekday(Number(v))}
options={WEEKDAY_OPTIONS.map((w) => ({
value: String(w),
label: t(`weekday.${w}`),
}))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required />
>
{WEEKDAY_OPTIONS.map((w) => (
<option key={w} value={w}>
{t(`weekday.${w}`)}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldPeriod")} required> <FormField label={t("form.fieldPeriod")} required>
<select <Select
value={period} value={String(period)}
onChange={(e) => setPeriod(Number(e.target.value))} onValueChange={(v) => setPeriod(Number(v))}
options={PERIOD_OPTIONS.map((p) => ({
value: String(p),
label: t("form.periodN", { n: p }),
}))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required />
>
{PERIOD_OPTIONS.map((p) => (
<option key={p} value={p}>
{t("form.periodN", { n: p })}
</option>
))}
</select>
</FormField> </FormField>
</div> </div>

View File

@@ -33,6 +33,7 @@ import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -185,19 +186,20 @@ export function DepartmentsClient(): React.ReactElement {
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => updateQuery("schoolId", e.target.value)} onValueChange={(v) => updateQuery("schoolId", v)}
options={[
{ value: "", label: t("allSchools") },
...(schools ?? []).map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder={t("allSchools")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterSchool")} aria-label={t("filterSchool")}
> />
<option value="">{t("allSchools")}</option>
{(schools ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</> </>
} }
loading={loading} loading={loading}
@@ -392,19 +394,19 @@ function DepartmentFormDialog({
/> />
</FormField> </FormField>
<FormField label={t("form.fieldSchool")} required> <FormField label={t("form.fieldSchool")} required>
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => setSchoolId(e.target.value)} onValueChange={(v) => setSchoolId(v)}
required options={[
{ value: "", label: "--" },
...schools.map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldHead")}> <FormField label={t("form.fieldHead")}>
<Input <Input

View File

@@ -397,8 +397,8 @@ export function GradeInsightsClient(): React.ReactElement {
<span <span
className={ className={
isUp isUp
? "inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400" ? "inline-flex items-center gap-1 text-success"
: "inline-flex items-center gap-1 text-rose-600 dark:text-rose-400" : "inline-flex items-center gap-1 text-destructive"
} }
> >
<DeltaIcon <DeltaIcon

View File

@@ -55,6 +55,7 @@ import {
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { StatCard } from "@/shared/components/ui/stat-card"; import { StatCard } from "@/shared/components/ui/stat-card";
import { import {
ListPageShell, ListPageShell,
@@ -231,19 +232,20 @@ export function GradesClient(): React.ReactElement {
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => updateQuery("schoolId", e.target.value)} onValueChange={(v) => updateQuery("schoolId", v)}
options={[
{ value: "", label: t("allSchools") },
...(schools ?? []).map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder={t("allSchools")}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("filterSchool")} aria-label={t("filterSchool")}
> />
<option value="">{t("allSchools")}</option>
{(schools ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</> </>
} }
loading={loading} loading={loading}
@@ -441,7 +443,7 @@ function GradeOverviewCards({
<div className="mt-0.5 text-sm font-semibold tabular-nums"> <div className="mt-0.5 text-sm font-semibold tabular-nums">
{formatCount(stats?.classCount ?? g.classCount)} {formatCount(stats?.classCount ?? g.classCount)}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-muted-foreground">
{t("statsClassCount")} {t("statsClassCount")}
</div> </div>
</div> </div>
@@ -452,7 +454,7 @@ function GradeOverviewCards({
<div className="mt-0.5 text-sm font-semibold tabular-nums"> <div className="mt-0.5 text-sm font-semibold tabular-nums">
{formatCount(stats?.studentCount ?? g.studentCount)} {formatCount(stats?.studentCount ?? g.studentCount)}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-muted-foreground">
{t("statsStudentCount")} {t("statsStudentCount")}
</div> </div>
</div> </div>
@@ -463,7 +465,7 @@ function GradeOverviewCards({
<div className="mt-0.5 text-sm font-semibold tabular-nums"> <div className="mt-0.5 text-sm font-semibold tabular-nums">
{formatScore(stats?.avgScore ?? null)} {formatScore(stats?.avgScore ?? null)}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-muted-foreground">
{t("statsAvgScore")} {t("statsAvgScore")}
</div> </div>
</div> </div>
@@ -642,33 +644,34 @@ function GradeFormDialog({
/> />
</FormField> </FormField>
<FormField label={t("form.fieldSchool")} required> <FormField label={t("form.fieldSchool")} required>
<select <Select
value={schoolId} value={schoolId}
onChange={(e) => setSchoolId(e.target.value)} onValueChange={(v) => setSchoolId(v)}
required options={[
{ value: "", label: "--" },
...schools.map((s) => ({
value: s.id,
label: s.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField> </FormField>
<FormField label={t("form.fieldHeadStaff")}> <FormField label={t("form.fieldHeadStaff")}>
<select <Select
value={headStaffId} value={headStaffId}
onChange={(e) => setHeadStaffId(e.target.value)} onValueChange={(v) => setHeadStaffId(v)}
options={[
{ value: "", label: "--" },
...staffOptions.map((stf) => ({
value: stf.id,
label: stf.name,
})),
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">--</option>
{staffOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormField> </FormField>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>

View File

@@ -55,7 +55,7 @@ export function truncateText(text: string, maxLen = 30): string {
*/ */
export function activeToBadgeClass(isActive: boolean): string { export function activeToBadgeClass(isActive: boolean): string {
return isActive return isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" ? "bg-success/10 text-success"
: "bg-muted text-muted-foreground"; : "bg-muted text-muted-foreground";
} }

View File

@@ -27,6 +27,7 @@ import type { AdminStudent } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -59,16 +60,11 @@ export function StudentsListClient(): React.ReactElement {
const classId = searchParams.get("classId") ?? ""; const classId = searchParams.get("classId") ?? "";
const pageParam = Number(searchParams.get("page") ?? "1"); const pageParam = Number(searchParams.get("page") ?? "1");
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1; const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
const offset = (page - 1) * PAGE_SIZE;
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useAdminStudents( const { data, loading, error } = useAdminStudents({
{
gradeId: gradeId || null, gradeId: gradeId || null,
classId: classId || null, classId: classId || null,
}, });
{ limit: PAGE_SIZE, offset },
);
const { data: gradesData } = useGrades(); const { data: gradesData } = useGrades();
const { data: classesData } = useAdminClasses(); const { data: classesData } = useAdminClasses();
@@ -165,32 +161,28 @@ export function StudentsListClient(): React.ReactElement {
value={search} value={search}
onChange={(v) => updateQuery("search", v, true)} onChange={(v) => updateQuery("search", v, true)}
/> />
<select <Select
value={gradeId} value={gradeId}
onChange={(e) => handleGradeChange(e.target.value)} onValueChange={(v) => handleGradeChange(v)}
aria-label={t("list.gradeFilter")} aria-label={t("list.gradeFilter")}
options={[
{ value: "", label: t("list.allGrades") },
...gradeOptions.map((g) => ({ value: g.id, label: g.name })),
]}
placeholder={t("list.allGrades")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("list.allGrades")}</option> <Select
{gradeOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<select
value={classId} value={classId}
onChange={(e) => updateQuery("classId", e.target.value, true)} onValueChange={(v) => updateQuery("classId", v, true)}
aria-label={t("list.classFilter")} aria-label={t("list.classFilter")}
options={[
{ value: "", label: t("list.allClasses") },
...classOptions.map((c) => ({ value: c.id, label: c.name })),
]}
placeholder={t("list.allClasses")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("list.allClasses")}</option>
{classOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</> </>
} }
loading={loading} loading={loading}

View File

@@ -24,8 +24,19 @@ import {
type SystemSettingInput, type SystemSettingInput,
} from "@/lib/api"; } from "@/lib/api";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { import {
FormPageShell, FormPageShell,
FormPageSkeleton, FormPageSkeleton,
@@ -97,6 +108,7 @@ export function SystemSettingsClient(): React.ReactElement {
const [storageBucket, setStorageBucket] = useState(""); const [storageBucket, setStorageBucket] = useState("");
const [storageRegion, setStorageRegion] = useState(""); const [storageRegion, setStorageRegion] = useState("");
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
// 当外部数据加载完成后,同步到本地表单状态 // 当外部数据加载完成后,同步到本地表单状态
useEffect(() => { useEffect(() => {
@@ -259,11 +271,15 @@ export function SystemSettingsClient(): React.ReactElement {
}; };
const handleReset = (): void => { const handleReset = (): void => {
if (!window.confirm(t("resetConfirm"))) return; setResetConfirmOpen(true);
};
const handleConfirmReset = (): void => {
if (data && data.length > 0) { if (data && data.length > 0) {
// 触发 useEffect 重新同步:通过 refetch 间接完成 // 触发 useEffect 重新同步:通过 refetch 间接完成
void refetch(); void refetch();
} }
setResetConfirmOpen(false);
}; };
if (loading) { if (loading) {
@@ -339,14 +355,15 @@ export function SystemSettingsClient(): React.ReactElement {
/> />
</FormField> </FormField>
<FormField label={t("generalLanguage")}> <FormField label={t("generalLanguage")}>
<select <Select
value={generalLanguage} value={generalLanguage}
onChange={(e) => setGeneralLanguage(e.target.value)} onValueChange={(v) => setGeneralLanguage(v)}
options={[
{ value: "zh-CN", label: "简体中文" },
{ value: "en", label: "English" },
]}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
> />
<option value="zh-CN"></option>
<option value="en">English</option>
</select>
</FormField> </FormField>
</SectionBlock> </SectionBlock>
@@ -432,17 +449,19 @@ export function SystemSettingsClient(): React.ReactElement {
{/* 存储配置分区 */} {/* 存储配置分区 */}
<SectionBlock title={t("sectionStorage")}> <SectionBlock title={t("sectionStorage")}>
<FormField label={t("storageProvider")}> <FormField label={t("storageProvider")}>
<select <Select
value={storageProvider} value={storageProvider}
onChange={(e) => setStorageProvider(e.target.value)} onValueChange={(v) => setStorageProvider(v)}
options={[
{ value: "", label: "--" },
{ value: "s3", label: "AWS S3" },
{ value: "oss", label: "Aliyun OSS" },
{ value: "cos", label: "Tencent COS" },
{ value: "minio", label: "MinIO" },
]}
placeholder="--"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
> />
<option value="">--</option>
<option value="s3">AWS S3</option>
<option value="oss">Aliyun OSS</option>
<option value="cos">Tencent COS</option>
<option value="minio">MinIO</option>
</select>
</FormField> </FormField>
<FormField label={t("storageBucket")}> <FormField label={t("storageBucket")}>
<Input <Input
@@ -462,6 +481,20 @@ export function SystemSettingsClient(): React.ReactElement {
</SectionBlock> </SectionBlock>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p> <p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetButton")}</AlertDialogTitle>
<AlertDialogDescription>{t("resetConfirm")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmReset}>
{t("resetButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</FormPageShell> </FormPageShell>
); );
} }

View File

@@ -23,6 +23,7 @@ import type { AdminTeacher } from "@/lib/api/admin-p5";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -53,15 +54,10 @@ export function TeachersListClient(): React.ReactElement {
const departmentId = searchParams.get("departmentId") ?? ""; const departmentId = searchParams.get("departmentId") ?? "";
const pageParam = Number(searchParams.get("page") ?? "1"); const pageParam = Number(searchParams.get("page") ?? "1");
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1; const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
const offset = (page - 1) * PAGE_SIZE;
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useAdminTeachers( const { data, loading, error } = useAdminTeachers({
{
department: departmentId || null, department: departmentId || null,
}, });
{ limit: PAGE_SIZE, offset },
);
const { data: departmentsData } = useDepartments(); const { data: departmentsData } = useDepartments();
const departmentOptions = useMemo( const departmentOptions = useMemo(
@@ -140,19 +136,17 @@ export function TeachersListClient(): React.ReactElement {
value={search} value={search}
onChange={(v) => updateQuery("search", v, true)} onChange={(v) => updateQuery("search", v, true)}
/> />
<select <Select
value={departmentId} value={departmentId}
onChange={(e) => updateQuery("departmentId", e.target.value, true)} onValueChange={(v) => updateQuery("departmentId", v, true)}
aria-label={t("list.departmentFilter")} aria-label={t("list.departmentFilter")}
options={[
{ value: "", label: t("list.allDepartments") },
...departmentOptions,
]}
placeholder={t("list.allDepartments")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("list.allDepartments")}</option>
{departmentOptions.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
</> </>
} }
loading={loading} loading={loading}

View File

@@ -41,6 +41,7 @@ import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
@@ -248,19 +249,17 @@ export function UsersListClient(): React.ReactElement {
value={search} value={search}
onChange={(v) => updateQuery("search", v, true)} onChange={(v) => updateQuery("search", v, true)}
/> />
<select <Select
value={role} value={role}
onChange={(e) => updateQuery("role", e.target.value, true)} onValueChange={(v) => updateQuery("role", v, true)}
aria-label={t("list.roleFilter")} aria-label={t("list.roleFilter")}
options={[
{ value: "", label: t("list.allRoles") },
...USER_ROLE_OPTIONS,
]}
placeholder={t("list.allRoles")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
> />
<option value="">{t("list.allRoles")}</option>
{USER_ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(search || role) && ( {(search || role) && (
<Button <Button
type="button" type="button"

View File

@@ -3,39 +3,43 @@
/** /**
* 学生考勤页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2 * 学生考勤页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
* *
* 数据契约: * 数据契约:studentAttendance ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - studentAttendance❌ schema 无此字段 → MSW 兜底(@contract-pending * 三态规范§11.3 DoDloading / error + notify.error / empty
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-attendance
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
* 复刻 CICDstudent-attendance-view.tsx
*/ */
import { ClipboardCheck } from "lucide-react"; import { CalendarCheck } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useStudentAttendance } from "@/lib/api"; import { useStudentAttendance } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card"; import { StatCard } from "@/shared/components/ui/stat-card";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { import {
DetailPageShell, DetailPageShell,
DetailPageSkeleton, DetailPageSkeleton,
DetailSection, DetailSection,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 考勤客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function StudentAttendanceClient(): React.ReactElement { export function StudentAttendanceClient(): React.ReactElement {
const t = useTranslations("studentDomain.attendance"); const t = useTranslations("studentDomain.attendance");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
// @contract-pendingMSW 兜底
const { data, loading, error } = useStudentAttendance(); const { data, loading, error } = useStudentAttendance();
useEffect(() => {
if (error) {
notify.error(tCommon("error.loadFailed", { message: String(error) }));
}
}, [error, tCommon]);
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -47,14 +51,14 @@ export function StudentAttendanceClient(): React.ReactElement {
const emptyNode = const emptyNode =
!loading && !error && !data ? ( !loading && !error && !data ? (
<EmptyState icon={ClipboardCheck} title={t("emptyTitle")} /> <EmptyState icon={CalendarCheck} title={t("emptyTitle")} />
) : undefined; ) : undefined;
return ( return (
<DetailPageShell <DetailPageShell
title={t("title")} title={t("title")}
description={t("description")} description={t("description")}
icon={<ClipboardCheck className="size-6" />} icon={<CalendarCheck className="size-6" />}
loading={loading} loading={loading}
loadingNode={<DetailPageSkeleton />} loadingNode={<DetailPageSkeleton />}
errorNode={errorNode} errorNode={errorNode}
@@ -65,9 +69,6 @@ export function StudentAttendanceClient(): React.ReactElement {
); );
} }
/**
* 考勤内容区(汇总卡片 + 明细列表)。
*/
function AttendanceBody({ function AttendanceBody({
data, data,
}: { }: {
@@ -76,11 +77,46 @@ function AttendanceBody({
const t = useTranslations("studentDomain.attendance"); const t = useTranslations("studentDomain.attendance");
return ( return (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("fieldStudentName")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tracking-tight">
{data.studentName ?? "--"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("fieldTotalRecords")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tracking-tight tabular-nums">
{String(data.totalRecords ?? data.records.length)}
</div>
</CardContent>
</Card>
</div>
<DetailSection title={t("sectionSummary")}> <DetailSection title={t("sectionSummary")}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<StatCard <StatCard
title={t("fieldAttendanceRate")} title={t("fieldTotalRecords")}
value={formatRate(data.rate)} value={String(data.totalRecords ?? data.records.length)}
/>
<StatCard
title={t("fieldPresentCount")}
value={String(data.presentCount ?? "--")}
/>
<StatCard
title={t("fieldAbsentCount")}
value={String(data.absentCount)}
/> />
<StatCard <StatCard
title={t("fieldLateCount")} title={t("fieldLateCount")}
@@ -95,8 +131,16 @@ function AttendanceBody({
value={String(data.leaveCount)} value={String(data.leaveCount)}
/> />
<StatCard <StatCard
title={t("fieldAbsentCount")} title={t("fieldSchoolActivityCount")}
value={String(data.absentCount)} value={String(data.schoolActivityCount ?? "--")}
/>
<StatCard
title={t("fieldAttendanceRate")}
value={formatRate(data.rate)}
/>
<StatCard
title={t("fieldLateRate")}
value={formatRate(data.lateRate)}
/> />
</div> </div>
</DetailSection> </DetailSection>
@@ -108,9 +152,6 @@ function AttendanceBody({
); );
} }
/**
* 考勤明细表格。
*/
function AttendanceRecordsTable({ function AttendanceRecordsTable({
records, records,
}: { }: {
@@ -128,6 +169,7 @@ function AttendanceRecordsTable({
<thead className="border-b bg-muted/30"> <thead className="border-b bg-muted/30">
<tr> <tr>
<th className="p-3 text-left font-medium">{t("colDate")}</th> <th className="p-3 text-left font-medium">{t("colDate")}</th>
<th className="p-3 text-left font-medium">{t("colClass")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th> <th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-left font-medium">{t("colRemark")}</th> <th className="p-3 text-left font-medium">{t("colRemark")}</th>
</tr> </tr>
@@ -138,6 +180,9 @@ function AttendanceRecordsTable({
<td className="p-3 font-mono text-xs text-muted-foreground"> <td className="p-3 font-mono text-xs text-muted-foreground">
{formatDate(record.date)} {formatDate(record.date)}
</td> </td>
<td className="p-3 text-xs text-muted-foreground">
{record.className ?? "--"}
</td>
<td className="p-3"> <td className="p-3">
<AttendanceStatusBadge status={record.status} /> <AttendanceStatusBadge status={record.status} />
</td> </td>
@@ -152,15 +197,13 @@ function AttendanceRecordsTable({
); );
} }
/**
* 考勤状态徽章(按状态色阶展示)。
*/
function AttendanceStatusBadge({ function AttendanceStatusBadge({
status, status,
}: { }: {
status: string; status: string;
}): React.ReactElement { }): React.ReactElement {
const label = formatAttendanceStatus(status); const t = useTranslations("studentDomain.attendance");
const label = formatAttendanceStatus(status, t);
const cls = attendanceStatusToBadgeClass(status); const cls = attendanceStatusToBadgeClass(status);
return ( return (
<span <span
@@ -171,21 +214,28 @@ function AttendanceStatusBadge({
); );
} }
/** 考勤状态中文标签映射。 */ function formatAttendanceStatus(
const ATTENDANCE_STATUS_LABEL: Record<string, string> = { status: string,
present: "出勤", t: ReturnType<typeof useTranslations>,
late: "迟到", ): string {
early_leave: "早退", switch (status) {
leave: "请假", case "present":
absent: "缺勤", return t("status.present");
}; case "late":
return t("status.late");
/** 将考勤状态枚举值映射为中文标签。未知状态回退为原始值。 */ case "early_leave":
function formatAttendanceStatus(status: string): string { return t("status.early_leave");
return ATTENDANCE_STATUS_LABEL[status] ?? status; case "leave":
return t("status.leave");
case "absent":
return t("status.absent");
case "school_activity":
return t("status.school_activity");
default:
return status;
}
} }
/** 根据考勤状态返回 Tailwind 徽章类名。 */
function attendanceStatusToBadgeClass(status: string): string { function attendanceStatusToBadgeClass(status: string): string {
switch (status) { switch (status) {
case "present": case "present":
@@ -198,18 +248,19 @@ function attendanceStatusToBadgeClass(status: string): string {
return "bg-blue-500/10 text-blue-600 dark:text-blue-400"; return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "absent": case "absent":
return "bg-destructive/10 text-destructive"; return "bg-destructive/10 text-destructive";
case "school_activity":
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
default: default:
return "bg-muted text-muted-foreground"; return "bg-muted text-muted-foreground";
} }
} }
/** 格式化 0-1 的出勤率为百分比字符串。 */ function formatRate(rate: number | undefined): string {
function formatRate(rate: number): string { if (rate === undefined || !Number.isFinite(rate) || rate < 0 || rate > 1)
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--"; return "--";
return `${(rate * 100).toFixed(1)}%`; return `${(rate * 100).toFixed(1)}%`;
} }
/** 格式化 ISO 日期字符串为本地化展示。 */
function formatDate(isoDate: string): string { function formatDate(isoDate: string): string {
if (!isoDate) return "--"; if (!isoDate) return "--";
const d = new Date(isoDate); const d = new Date(isoDate);

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
// @contract-pendingstudentCoursePlanDetail schema 未实现,全 MSW 兜底
/** /**
* 学生课程计划详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2 * 学生课程计划详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
* *
@@ -10,33 +9,42 @@
* *
* 三态规范§11.3 DoD * 三态规范§11.3 DoD
* - loadingDetailPageSkeleton * - loadingDetailPageSkeleton
* - errorerrorNode 局部降级 * - errorerrorNode 局部降级 + notify.error
* - notFounddata 为 null 时显示空态节点 * - notFounddata 为 null 时显示空态节点
* *
* 学生视角:只读,无拖拽/编辑/删除/批量操作。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { ClipboardList, BookOpen } from "lucide-react"; import { ClipboardList, Download } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect, type ReactElement } from "react";
import { import {
useStudentCoursePlanDetail, useStudentCoursePlanDetail,
type StudentCoursePlanDetail as CoursePlanDetailData, type StudentCoursePlanDetail as CoursePlanDetailData,
type StudentCoursePlanItem,
type StudentCoursePlanItemStatus,
} from "@/lib/api"; } from "@/lib/api";
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 { EmptyState } from "@/shared/components/ui/empty-state";
import { import {
DetailPageShell, DetailPageShell,
DetailPageSkeleton, DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
/** /** 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 */
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 export function StudentCoursePlanDetailClient(): ReactElement {
*/
export function StudentCoursePlanDetailClient(): React.ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail"); const t = useTranslations("studentDomain.coursePlans.detail");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -86,41 +94,425 @@ export function StudentCoursePlanDetailClient(): React.ReactElement {
); );
} }
/** /** 详情主体Header Card + 周计划表格)。 */
* 详情主体(基本信息 + 关联教材列表)。
*/
function CoursePlanDetailBody({ function CoursePlanDetailBody({
plan, plan,
}: { }: {
plan: CoursePlanDetailData; plan: CoursePlanDetailData;
}): React.ReactElement { }): ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail"); const t = useTranslations("studentDomain.coursePlans.detail");
return (
<>
<DetailSection title={t("sectionBasic")}>
<DetailField label={t("fieldTitle")} value={plan.title} />
<DetailField label={t("fieldSubject")} value={plan.subject} />
<DetailField label={t("fieldGrade")} value={plan.grade} />
<DetailField label={t("fieldDescription")} value={plan.description} />
</DetailSection>
<DetailSection title={t("sectionTextbooks")}> const handleExport = (): void => {
{plan.textbooks.length === 0 ? ( try {
<p className="text-sm text-muted-foreground">{t("notFound")}</p> const csv = exportPlanAsCsv(plan, t);
) : ( downloadCsv(
<ul className="space-y-2"> csv,
{plan.textbooks.map((title, idx) => ( `${plan.subject}-${plan.className ?? "no-class"}-course-plan.csv`,
<li );
key={`${title}-${idx}`} notify.success(t("toastExported"));
className="flex items-center gap-2 rounded-md border bg-card p-3 text-sm" } catch {
> notify.error(t("toastExportFailed"));
<BookOpen className="size-4 text-muted-foreground" /> }
<span className="font-medium">{title}</span> };
</li>
))} return (
</ul> <div className="flex flex-col gap-6">
)} <SectionErrorBoundary title={t("sectionBasic")}>
</DetailSection> <HeaderCard plan={plan} onExport={handleExport} />
</> </SectionErrorBoundary>
<SectionErrorBoundary title={t("sectionWeekPlans")}>
<WeeklyPlansCard plan={plan} />
</SectionErrorBoundary>
</div>
); );
} }
/** Header Card标题、徽章、教师、日期、进度、大纲、目标。 */
function HeaderCard({
plan,
onExport,
}: {
plan: CoursePlanDetailData;
onExport: () => void;
}): ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail");
return (
<Card>
<CardHeader className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{plan.className ?? t("badgeNoClass")}</Badge>
<Badge variant="outline">
{plan.subject ?? t("badgeUnknownSubject")}
</Badge>
{plan.status ? (
<Badge>{formatPlanStatus(plan.status, t)}</Badge>
) : null}
{plan.semester ? (
<Badge variant="outline">
{t("fieldSemester", { semester: plan.semester })}
</Badge>
) : null}
</div>
<CardTitle className="text-xl">
{plan.subject ?? t("badgeUnknownSubject")} {" "}
{plan.className ?? t("badgeNoClass")}
</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{plan.teacherName
? `${t("fieldTeacher")}: ${plan.teacherName}`
: t("fieldUnassigned")}
</span>
{plan.createdAt ? (
<span>
· {t("fieldCreatedAt", { date: formatDate(plan.createdAt) })}
</span>
) : null}
{plan.startDate ? (
<span>
· {t("fieldStartDate", { date: formatDate(plan.startDate) })}
</span>
) : null}
{plan.endDate ? (
<span>
· {t("fieldEndDate", { date: formatDate(plan.endDate) })}
</span>
) : null}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
onClick={onExport}
variant="outline"
size="sm"
disabled={!plan.items || plan.items.length === 0}
>
<Download className="mr-2 size-4" aria-hidden="true" />
{t("exportCsv")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CoursePlanProgress plan={plan} />
<SyllabusObjectives plan={plan} />
</CardContent>
</Card>
);
}
/** 进度条:完成学时 + 完成项数。纯 Tailwind div 实现,无新依赖。 */
function CoursePlanProgress({
plan,
}: {
plan: CoursePlanDetailData;
}): ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail");
const completedHours = plan.completedHours ?? 0;
const totalHours = plan.totalHours ?? 0;
const hoursPercent =
totalHours > 0
? Math.min(100, Math.round((completedHours / totalHours) * 100))
: 0;
const completedItems = plan.completedItems ?? 0;
const totalItems = plan.totalItems ?? 0;
const itemsPercent =
totalItems > 0
? Math.min(100, Math.round((completedItems / totalItems) * 100))
: 0;
return (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{t("progressLabel")}</span>
<span className="text-muted-foreground">
{t("progressHours", {
completed: completedHours,
total: totalHours,
percent: hoursPercent,
})}
</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={hoursPercent}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${hoursPercent}%` }}
aria-hidden="true"
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("sectionWeekPlans")}</span>
<span className="text-muted-foreground">
{t("progressItems", {
completed: completedItems,
total: totalItems,
})}
</span>
</div>
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={itemsPercent}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full rounded-full bg-emerald-500/60 transition-all"
style={{ width: `${itemsPercent}%` }}
aria-hidden="true"
/>
</div>
</div>
</div>
);
}
/** 教学大纲 + 教学目标文本区。 */
function SyllabusObjectives({
plan,
}: {
plan: CoursePlanDetailData;
}): ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail");
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<h4 className="text-sm font-semibold">{t("sectionSyllabus")}</h4>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{plan.syllabus && plan.syllabus.trim().length > 0
? plan.syllabus
: t("emptyText")}
</p>
</div>
<div className="space-y-1">
<h4 className="text-sm font-semibold">{t("sectionObjectives")}</h4>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{plan.objectives && plan.objectives.trim().length > 0
? plan.objectives
: t("emptyText")}
</p>
</div>
</div>
);
}
/** 周计划表格卡片。学生视角只读。 */
function WeeklyPlansCard({
plan,
}: {
plan: CoursePlanDetailData;
}): ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail");
const items = plan.items ?? [];
return (
<Card>
<CardHeader>
<CardTitle>{t("sectionWeekPlans")}</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyWeekPlans")}
</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="w-16 p-3 text-left font-medium">
{t("colWeek")}
</th>
<th className="p-3 text-left font-medium">{t("colTopic")}</th>
<th className="w-20 p-3 text-left font-medium">
{t("colHours")}
</th>
<th className="w-40 p-3 text-left font-medium">
{t("colChapter")}
</th>
<th className="w-28 p-3 text-left font-medium">
{t("colStatus")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<WeeklyPlanRow key={item.id} item={item} />
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
/** 周计划表格行:圆形周次徽章 + 主题 + 时长 + 章节 + 状态 Badge。 */
function WeeklyPlanRow({
item,
}: {
item: StudentCoursePlanItem;
}): ReactElement {
return (
<tr className="hover:bg-muted/30">
<td className="p-3">
<span className="inline-flex size-7 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{item.week}
</span>
</td>
<td className="p-3 font-medium">{item.topic}</td>
<td className="p-3 text-muted-foreground">{item.hours}</td>
<td className="p-3 text-muted-foreground">{item.chapter ?? "—"}</td>
<td className="p-3">
<StatusBadge status={item.status} />
</td>
</tr>
);
}
/** 周计划项状态徽章。 */
function StatusBadge({
status,
}: {
status: StudentCoursePlanItemStatus;
}): ReactElement {
const t = useTranslations("studentDomain.coursePlans.detail");
return (
<Badge variant="outline" className={statusToBadgeClass(status)}>
{formatItemStatus(status, t)}
</Badge>
);
}
// ===== 工具函数 =====
/** 格式化 ISO 日期为本地化短日期。 */
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/** 格式化课程计划状态(后端返回的状态字符串)。 */
function formatPlanStatus(
status: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (status) {
case "planned":
case "PLANNED":
case "DRAFT":
return t("statusPlanned");
case "in_progress":
case "IN_PROGRESS":
return t("statusInProgress");
case "completed":
case "COMPLETED":
return t("statusCompleted");
case "skipped":
case "ARCHIVED":
return t("statusSkipped");
default:
return status;
}
}
/** 格式化周计划项状态。 */
function formatItemStatus(
status: StudentCoursePlanItemStatus,
t: ReturnType<typeof useTranslations>,
): string {
switch (status) {
case "planned":
return t("statusPlanned");
case "in_progress":
return t("statusInProgress");
case "completed":
return t("statusCompleted");
case "skipped":
return t("statusSkipped");
default:
return t("statusUnknown");
}
}
/** 周计划项状态 → Tailwind 徽章类名映射。 */
function statusToBadgeClass(status: StudentCoursePlanItemStatus): string {
switch (status) {
case "completed":
return "border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "in_progress":
return "border-transparent bg-primary/10 text-primary";
case "planned":
return "border-transparent bg-muted text-muted-foreground";
case "skipped":
return "border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400";
default:
return "border-transparent bg-muted text-muted-foreground";
}
}
/** 导出课程计划为 CSV 字符串。 */
function exportPlanAsCsv(
plan: CoursePlanDetailData,
t: ReturnType<typeof useTranslations>,
): string {
const headers = [
t("colWeek"),
t("colTopic"),
t("colHours"),
t("colChapter"),
t("colStatus"),
];
const rows = (plan.items ?? []).map((item) => [
String(item.week),
item.topic,
String(item.hours),
item.chapter ?? "",
formatItemStatus(item.status, t),
]);
const allRows = [headers, ...rows];
return allRows
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(","))
.join("\n");
}
/** CSV 单元格转义。 */
function escapeCsvCell(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/** 触发浏览器下载 CSV 文件。 */
function downloadCsv(csv: string, filename: string): void {
const blob = new Blob([`\uFEFF${csv}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

View File

@@ -8,29 +8,59 @@
* - studentCoursePlans ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentCoursePlans ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* *
* URL 状态:?status=(客户端按状态过滤)
*
* 视图结构Card 网格布局):
* - 状态筛选 Select全部/进行中/已完成/计划中/已暂停)
* - 课程计划 Card 网格(标题 + 班级/教师/学期/进度条 + 状态徽章)
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { ClipboardList } from "lucide-react"; import { ClipboardList } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect } from "react";
import { useStudentCoursePlans, type StudentCoursePlan } from "@/lib/api"; import { useStudentCoursePlans, type StudentCoursePlan } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import {
FilterBar,
FilterSearchInput,
} from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { cn } from "@/shared/lib/utils";
/** 状态筛选选项value 与后端 status 字符串对齐) */
const STATUS_FILTER_OPTIONS: readonly { value: string; labelKey: string }[] = [
{ value: "", labelKey: "statusAll" },
{ value: "active", labelKey: "statusActive" },
{ value: "completed", labelKey: "statusCompleted" },
{ value: "planning", labelKey: "statusPlanning" },
{ value: "paused", labelKey: "statusPaused" },
] as const;
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/ */
export function StudentCoursePlansListClient(): React.ReactElement { export function StudentCoursePlansListClient(): React.ReactElement {
const t = useTranslations("studentDomain.coursePlans.list"); const t = useTranslations("studentDomain.coursePlans.list");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useStudentCoursePlans(); const { data, loading, error } = useStudentCoursePlans();
@@ -43,6 +73,37 @@ export function StudentCoursePlansListClient(): React.ReactElement {
const items = data ?? []; const items = data ?? [];
// 客户端按状态/名称过滤
const filteredItems = useMemo<StudentCoursePlan[]>(() => {
return items.filter((plan) => {
if (statusFilter && plan.status !== statusFilter) return false;
if (q) {
const lower = q.toLowerCase();
if (
!plan.title.toLowerCase().includes(lower) &&
!plan.subject.toLowerCase().includes(lower) &&
!(plan.className ?? "").toLowerCase().includes(lower) &&
!(plan.teacherName ?? "").toLowerCase().includes(lower)
) {
return false;
}
}
return true;
});
}, [items, statusFilter, q]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/student/course-plans?${params.toString()}`);
});
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -68,69 +129,236 @@ export function StudentCoursePlansListClient(): React.ReactElement {
title={t("title")} title={t("title")}
description={t("description")} description={t("description")}
icon={<ClipboardList className="size-6" />} icon={<ClipboardList className="size-6" />}
loading={loading} filters={
<FilterBar variant="wrap">
<FilterSearchInput
placeholder={t("searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<Select
value={statusFilter}
onValueChange={(v) => updateQuery("status", v)}
options={STATUS_FILTER_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey),
}))}
placeholder={t("filterByStatus")}
aria-label={t("filterByStatus")}
className="md:w-40"
/>
</FilterBar>
}
loading={loading && items.length === 0}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
empty={items.length === 0 && !loading} empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode} emptyNode={emptyNode}
errorNode={errorNode} errorNode={errorNode}
pagination={ pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: items.length })}</span> <span className="text-xs">{t("mswNotice")}</span>
<span>{t("total", { count: filteredItems.length })}</span>
</div> </div>
} }
> >
<StudentCoursePlansTable items={items} /> <CoursePlanCardGrid items={filteredItems} />
</ListPageShell> </ListPageShell>
); );
} }
/** /**
* 学生课程计划列表表格(纯展示组件,对齐 §8.2 排版规范)。 * 课程计划 Card 网格(小屏 1 列,桌面 2-3 列)。
*/ */
function StudentCoursePlansTable({ function CoursePlanCardGrid({
items, items,
}: { }: {
items: StudentCoursePlan[]; items: StudentCoursePlan[];
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.coursePlans.list");
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((plan) => ( {items.map((plan) => (
<tr key={plan.id} className="hover:bg-muted/30"> <CoursePlanCard key={plan.id} plan={plan} />
<td className="p-3"> ))}
</div>
);
}
/**
* 课程计划 Card 单卡片:标题 + 状态徽章 + 班级/教师/学期 + 进度条 + 查看详情。
*/
function CoursePlanCard({
plan,
}: {
plan: StudentCoursePlan;
}): React.ReactElement {
const t = useTranslations("studentDomain.coursePlans.list");
const completedHours = plan.completedHours ?? 0;
const totalHours = plan.totalHours ?? 0;
const hoursPercent =
totalHours > 0
? Math.min(100, Math.round((completedHours / totalHours) * 100))
: 0;
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<Link <Link
href={`/shell/student/course-plans/${plan.id}`} href={`/shell/student/course-plans/${plan.id}`}
className="font-medium hover:underline" className="line-clamp-2 font-medium hover:underline"
> >
{plan.title} {plan.title}
</Link> </Link>
</td> <p className="text-xs text-muted-foreground">
<td className="p-3 text-muted-foreground">{plan.subject}</td> <span className="font-medium text-foreground">
<td className="p-3 text-muted-foreground">{plan.grade}</td> {t("colSubject")}
<td className="p-3 text-muted-foreground">{plan.status}</td> </span>
<td className="p-3 text-right"> : {plan.subject}
<span className="ml-2 font-medium text-foreground">
{t("colGrade")}
</span>
: {plan.grade}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={plan.status} />
</div>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
<div>
<span className="font-medium text-foreground">{t("colClass")}</span>:{" "}
{plan.className ?? "--"}
</div>
<div>
<span className="font-medium text-foreground">{t("colTeacher")}</span>
: {plan.teacherName ?? "--"}
</div>
<div>
<span className="font-medium text-foreground">
{t("colSemester")}
</span>
: {plan.semester ?? "--"}
</div>
<div>
<span className="font-medium text-foreground">{t("colCreated")}</span>
: {plan.createdAt ? formatDate(plan.createdAt) : "--"}
</div>
</div>
{/* 进度条:完成学时 / 总学时 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">
{t("fieldProgress")}
</span>
<span className="text-muted-foreground">
{t("progressHours", {
completed: completedHours,
total: totalHours,
percent: hoursPercent,
})}
</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={hoursPercent}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={cn(
"h-full rounded-full transition-all",
hoursPercent >= 100
? "bg-emerald-500/60"
: hoursPercent > 0
? "bg-primary"
: "bg-muted-foreground/30",
)}
style={{ width: `${hoursPercent}%` }}
aria-hidden="true"
/>
</div>
</div>
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
<Link <Link
href={`/shell/student/course-plans/${plan.id}`} href={`/shell/student/course-plans/${plan.id}`}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
{t("viewDetail")} {t("viewDetail")}
</Link> </Link>
</td> </div>
</tr>
))}
</tbody>
</table>
</div> </div>
); );
} }
/**
* 课程计划状态徽章(按状态色阶展示)。
*/
function StatusBadge({ status }: { status: string }): React.ReactElement {
const t = useTranslations("studentDomain.coursePlans.list");
const label = formatPlanStatus(status, t);
const cls = statusToBadgeClass(status);
return (
<span
className={
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
cls
}
>
{label}
</span>
);
}
/**
* 状态 → 徽章色阶映射(对齐 §3.10 设计令牌规范)。
*/
function statusToBadgeClass(status: string): string {
switch (status) {
case "active":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "completed":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "planning":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "paused":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/** 格式化课程计划状态(后端返回的状态字符串)。 */
function formatPlanStatus(
status: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (status) {
case "active":
return t("statusActive");
case "completed":
return t("statusCompleted");
case "planning":
return t("statusPlanning");
case "paused":
return t("statusPaused");
default:
return status;
}
}
/** 格式化 ISO 日期为本地化短日期。 */
function formatDate(iso: string): string {
if (!iso) return "--";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}

View File

@@ -13,18 +13,22 @@
* - errorerrorNode 局部降级 * - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点 * - notFounddata 为 null 时显示空态节点
* *
* 布局3 卡片网格(基本信息 / 教师信息 / 本班课表),小屏单列堆叠。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { GraduationCap } from "lucide-react"; import { GraduationCap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { import {
useStudentCourseDetail, useStudentCourseDetail,
type StudentCourseDetail as StudentCourseDetailData, type StudentCourseDetail as StudentCourseDetailData,
type StudentCourseScheduleItem, type StudentCourseScheduleItem,
} from "@/lib/api"; } from "@/lib/api";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { import {
@@ -35,6 +39,46 @@ import {
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
/** Weekday i18n key mapping1=周一 ... 7=周日) */
function weekdayI18nKey(weekday: number): string {
switch (weekday) {
case 1:
return "weekdayMon";
case 2:
return "weekdayTue";
case 3:
return "weekdayWed";
case 4:
return "weekdayThu";
case 5:
return "weekdayFri";
case 6:
return "weekdaySat";
case 7:
return "weekdaySun";
default:
return "weekdayMon";
}
}
/** Weekday badge variant周末用 secondary 区分) */
function weekdayBadgeVariant(
weekday: number,
): "default" | "secondary" | "outline" {
if (weekday === 6 || weekday === 7) return "secondary";
return "outline";
}
/** 按星期升序、再按节次升序排序后的课表(不可变副本) */
function sortSchedule(
schedule: readonly StudentCourseScheduleItem[],
): StudentCourseScheduleItem[] {
return [...schedule].sort((a, b) => {
if (a.weekday !== b.weekday) return a.weekday - b.weekday;
return a.period - b.period;
});
}
/** /**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/ */
@@ -89,7 +133,8 @@ export function StudentCourseDetailClient(): React.ReactElement {
} }
/** /**
* 详情主体(基本信息 + 教师信息 + 本班课表)。 * 详情主体(3 卡片网格:基本信息 + 教师信息 + 本班课表)。
* 小屏单列堆叠lg+ 三列网格。
*/ */
function CourseDetailBody({ function CourseDetailBody({
course, course,
@@ -98,16 +143,21 @@ function CourseDetailBody({
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.courses.detail"); const t = useTranslations("studentDomain.courses.detail");
return ( return (
<> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<DetailSection title={t("sectionBasic")}> <DetailSection title={t("sectionBasic")} className="lg:col-span-1">
<div className="grid grid-cols-1 gap-x-4 gap-y-1">
<DetailField label={t("fieldName")} value={course.name} /> <DetailField label={t("fieldName")} value={course.name} />
<DetailField label={t("fieldGrade")} value={course.grade} /> <DetailField label={t("fieldGrade")} value={course.grade} />
<DetailField label={t("fieldHeadTeacher")} value={course.headTeacher} /> <DetailField
label={t("fieldHeadTeacher")}
value={course.headTeacher}
/>
<DetailField label={t("fieldClassroom")} value={course.classroom} /> <DetailField label={t("fieldClassroom")} value={course.classroom} />
<DetailField label={t("fieldSchool")} value={course.school} /> <DetailField label={t("fieldSchool")} value={course.school} />
</div>
</DetailSection> </DetailSection>
<DetailSection title={t("sectionTeachers")}> <DetailSection title={t("sectionTeachers")} className="lg:col-span-1">
{course.teachers.length === 0 ? ( {course.teachers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("notFound")}</p> <p className="text-sm text-muted-foreground">{t("notFound")}</p>
) : ( ) : (
@@ -138,20 +188,28 @@ function CourseDetailBody({
<DetailSection <DetailSection
title={t("sectionSchedule")} title={t("sectionSchedule")}
className="lg:col-span-1"
actions={ actions={
<Button variant="outline" size="sm"> <div className="flex flex-wrap gap-2">
{t("viewFullSchedule")} <Button asChild variant="outline" size="sm">
<Link href="/shell/student/homework">{t("viewHomework")}</Link>
</Button> </Button>
<Button asChild variant="outline" size="sm">
<Link href="/shell/student/schedule">
{t("viewFullSchedule")}
</Link>
</Button>
</div>
} }
> >
<ScheduleTable schedule={course.schedule} /> <ScheduleTable schedule={course.schedule} />
</DetailSection> </DetailSection>
</> </div>
); );
} }
/** /**
* 本班课表表格。 * 本班课表表格(按星期/节次排序,含 weekday 徽章与全部字段)
*/ */
function ScheduleTable({ function ScheduleTable({
schedule, schedule,
@@ -159,28 +217,45 @@ function ScheduleTable({
schedule: StudentCourseScheduleItem[]; schedule: StudentCourseScheduleItem[];
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.courses.detail"); const t = useTranslations("studentDomain.courses.detail");
if (schedule.length === 0) { const sorted = useMemo(() => sortSchedule(schedule), [schedule]);
if (sorted.length === 0) {
return <p className="text-sm text-muted-foreground">{t("notFound")}</p>; return <p className="text-sm text-muted-foreground">{t("notFound")}</p>;
} }
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/30"> <thead className="border-b bg-muted/30">
<tr> <tr>
<th className="p-3 text-left font-medium">{t("fieldName")}</th> <th className="p-3 text-left font-medium">{t("colWeekday")}</th>
<th className="p-3 text-left font-medium">{t("colPeriod")}</th>
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
<th className="p-3 text-left font-medium">{t("colTeacherName")}</th> <th className="p-3 text-left font-medium">{t("colTeacherName")}</th>
<th className="p-3 text-left font-medium">{t("fieldClassroom")}</th> <th className="p-3 text-left font-medium">{t("fieldClassroom")}</th>
<th className="p-3 text-left font-medium">{t("colTime")}</th>
<th className="p-3 text-left font-medium">{t("colCourse")}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{schedule.map((item, idx) => ( {sorted.map((item, idx) => (
<tr <tr
key={`${item.weekday}-${item.period}-${idx}`} key={`${item.weekday}-${item.period}-${idx}`}
className="hover:bg-muted/30" className="hover:bg-muted/30"
> >
<td className="p-3">
<Badge variant={weekdayBadgeVariant(item.weekday)}>
{t(weekdayI18nKey(item.weekday))}
</Badge>
</td>
<td className="p-3 text-muted-foreground">{item.period}</td>
<td className="p-3 font-medium">{item.subject}</td> <td className="p-3 font-medium">{item.subject}</td>
<td className="p-3 text-muted-foreground">{item.teacher}</td> <td className="p-3 text-muted-foreground">{item.teacher}</td>
<td className="p-3 text-muted-foreground">{item.classroom}</td> <td className="p-3 text-muted-foreground">{item.classroom}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{item.time}
</td>
<td className="p-3 text-muted-foreground">{item.course}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -6,28 +6,65 @@
* *
* 数据契约: * 数据契约:
* - studentCourses(q) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentCourses(q) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - joinClassByInvitationCode(input) ❌ schema 无此 mutation → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* *
* URL 状态:?q= * URL 状态:?q=
* *
* 视图模式card默认卡片网格/ table表格
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { GraduationCap } from "lucide-react"; import {
GraduationCap,
LayoutGrid,
List,
Mail,
MapPin,
UserPlus,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useTransition } from "react"; import { useState, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useStudentCourses, type StudentCourse } from "@/lib/api"; import {
useJoinClassByInvitationCode,
useStudentCourses,
type StudentCourse,
} from "@/lib/api";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
type ViewMode = "card" | "table";
/** 邀请码校验6 位数字 */
const INVITE_CODE_PATTERN = /^\d{6}$/;
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。 * useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -40,6 +77,8 @@ export function StudentCoursesListClient(): React.ReactElement {
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
const [viewMode, setViewMode] = useState<ViewMode>("card");
const [joinOpen, setJoinOpen] = useState(false);
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useStudentCourses({ const { data, loading, error } = useStudentCourses({
@@ -60,6 +99,12 @@ export function StudentCoursesListClient(): React.ReactElement {
}); });
}; };
const handleJoinSuccess = (): void => {
setJoinOpen(false);
startTransition(() => {
router.refresh();
});
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -86,11 +131,56 @@ export function StudentCoursesListClient(): React.ReactElement {
description={t("description")} description={t("description")}
icon={<GraduationCap className="size-6" />} icon={<GraduationCap className="size-6" />}
filters={ filters={
<>
<FilterSearchInput <FilterSearchInput
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<div className="ml-auto flex items-center gap-2">
<div
className="flex items-center gap-1 rounded-md border border-input p-0.5"
role="group"
aria-label={t("viewModeLabel")}
>
<button
type="button"
aria-label={t("viewCard")}
onClick={() => setViewMode("card")}
className={
"inline-flex h-8 items-center rounded px-2 text-xs transition-colors " +
(viewMode === "card"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground")
}
>
<LayoutGrid className="size-3.5" />
</button>
<button
type="button"
aria-label={t("viewTable")}
onClick={() => setViewMode("table")}
className={
"inline-flex h-8 items-center rounded px-2 text-xs transition-colors " +
(viewMode === "table"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground")
}
>
<List className="size-3.5" />
</button>
</div>
<Button
type="button"
variant="default"
size="sm"
onClick={() => setJoinOpen(true)}
>
<UserPlus className="size-4" />
{t("joinClass")}
</Button>
</div>
</>
} }
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
@@ -103,11 +193,118 @@ export function StudentCoursesListClient(): React.ReactElement {
</div> </div>
} }
> >
{viewMode === "card" ? (
<StudentCoursesCardGrid items={items} />
) : (
<StudentCoursesTable items={items} /> <StudentCoursesTable items={items} />
)}
<JoinClassDialog
open={joinOpen}
onOpenChange={setJoinOpen}
onSuccess={handleJoinSuccess}
/>
</ListPageShell> </ListPageShell>
); );
} }
/**
* 学生课程卡片网格(响应式:小屏 1 列sm 2 列lg 3 列)。
*/
function StudentCoursesCardGrid({
items,
}: {
items: StudentCourse[];
}): React.ReactElement {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((course) => (
<StudentCourseCard key={course.id} course={course} />
))}
</div>
);
}
/**
* 单个课程卡片。
*/
function StudentCourseCard({
course,
}: {
course: StudentCourse;
}): React.ReactElement {
const t = useTranslations("studentDomain.courses.list");
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">
<Link
href={`/shell/student/courses/${course.id}`}
className="hover:underline"
>
{course.name}
</Link>
</CardTitle>
{typeof course.isActive === "boolean" ? (
<Badge variant={course.isActive ? "default" : "secondary"}>
{course.isActive ? t("statusActive") : t("statusInactive")}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">
{t("cardTeacher")}
</span>
<span>{course.teacher}</span>
{course.teacherEmail ? (
<a
href={`mailto:${course.teacherEmail}`}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
aria-label={t("sendEmail")}
>
<Mail className="size-3.5" />
</a>
) : null}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">{t("cardSchool")}</span>
<span>{course.school}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">
{t("cardHeadTeacher")}
</span>
<span>{course.headTeacher}</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-muted-foreground">
{course.grade ? (
<span className="inline-flex items-center gap-1">
<span className="font-medium text-foreground">
{t("cardGrade")}
</span>
<span>{course.grade}</span>
</span>
) : null}
{course.room ? (
<span className="inline-flex items-center gap-1">
<MapPin className="size-3.5" />
<span>{course.room}</span>
</span>
) : null}
</div>
<div className="mt-auto pt-3">
<Link
href={`/shell/student/courses/${course.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{t("viewDetail")}
</Link>
</div>
</CardContent>
</Card>
);
}
/** /**
* 学生课程列表表格(纯展示组件,对齐 §8.2 排版规范)。 * 学生课程列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/ */
@@ -126,6 +323,8 @@ function StudentCoursesTable({
<th className="p-3 text-left font-medium">{t("colTeacher")}</th> <th className="p-3 text-left font-medium">{t("colTeacher")}</th>
<th className="p-3 text-left font-medium">{t("colSchool")}</th> <th className="p-3 text-left font-medium">{t("colSchool")}</th>
<th className="p-3 text-left font-medium">{t("colHeadTeacher")}</th> <th className="p-3 text-left font-medium">{t("colHeadTeacher")}</th>
<th className="p-3 text-left font-medium">{t("colRoom")}</th>
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th> <th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr> </tr>
</thead> </thead>
@@ -140,11 +339,30 @@ function StudentCoursesTable({
{course.name} {course.name}
</Link> </Link>
</td> </td>
<td className="p-3 text-muted-foreground">{course.teacher}</td> <td className="p-3 text-muted-foreground">
<div className="flex items-center gap-1">
<span>{course.teacher}</span>
{course.teacherEmail ? (
<a
href={`mailto:${course.teacherEmail}`}
className="inline-flex items-center text-primary hover:underline"
aria-label={t("sendEmail")}
>
<Mail className="size-3.5" />
</a>
) : null}
</div>
</td>
<td className="p-3 text-muted-foreground">{course.school}</td> <td className="p-3 text-muted-foreground">{course.school}</td>
<td className="p-3 text-muted-foreground"> <td className="p-3 text-muted-foreground">
{course.headTeacher} {course.headTeacher}
</td> </td>
<td className="p-3 text-muted-foreground">
{course.room ?? "-"}
</td>
<td className="p-3 text-muted-foreground">
{course.grade ?? "-"}
</td>
<td className="p-3 text-right"> <td className="p-3 text-right">
<Link <Link
href={`/shell/student/courses/${course.id}`} href={`/shell/student/courses/${course.id}`}
@@ -160,3 +378,111 @@ function StudentCoursesTable({
</div> </div>
); );
} }
/**
* 加入班级对话框(邀请码)。
* @contract-pendingjoinClassByInvitationCode mutation MSW 兜底
*/
function JoinClassDialog({
open,
onOpenChange,
onSuccess,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.courses.list");
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const joinClass = useJoinClassByInvitationCode();
const resetForm = (): void => {
setCode("");
};
const handleOpenChange = (next: boolean): void => {
if (!next) {
resetForm();
}
onOpenChange(next);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) {
notify.error(t("joinClassCodeRequired"));
return;
}
if (!INVITE_CODE_PATTERN.test(trimmed)) {
notify.error(t("joinClassCodeInvalid"));
return;
}
setSubmitting(true);
void (async (): Promise<void> => {
try {
const result = await joinClass.run({ code: trimmed });
if (result.success) {
notify.success(t("joinClassSuccess"));
resetForm();
onSuccess();
} else {
notify.error(result.message ?? t("joinClassFailed"));
}
} catch (err) {
notify.error(t("joinClassFailed", { message: String(err) }));
} finally {
setSubmitting(false);
}
})();
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("joinClassTitle")}</DialogTitle>
<DialogDescription>{t("joinClassDescription")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="invite-code">{t("joinClassCodeLabel")}</Label>
<Input
id="invite-code"
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={6}
placeholder={t("joinClassCodePlaceholder")}
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
}
aria-describedby="invite-code-hint"
/>
<p id="invite-code-hint" className="text-xs text-muted-foreground">
{t("joinClassCodeHint")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
disabled={submitting}
>
{t("joinClassCancel")}
</Button>
<Button
type="submit"
size="sm"
disabled={submitting || code.length !== 6}
>
{submitting ? t("joinClassSubmitting") : t("joinClassSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,29 +1,31 @@
"use client"; "use client";
// @contract-pendingelectiveCourses schema 未实现,全 MSW 兜底 // @contract-pending: studentElectiveDetail schema not implemented, MSW fallback
/** /**
* 学生选课详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P3 * Student Elective Detail - Client Component (ARCHITECTURE.md §7.3 / §9.1 / §10 P3)
* *
* 数据契约: * Data contract:
* - electiveCourses(termId) schema 无此字段 → MSW 兜底(@contract-pending * - studentElectiveDetail(id) -> schema missing -> MSW fallback (@contract-pending)
* - 详情复用列表 hook按 id 客户端过滤(无独立详情 hook * - Contract ticket: docs/architecture/issues/contracts/core-edu_contract.md#student-elective
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-elective
* *
* 隐私保护:学生视角不展示选课名单(仅展示课程基本信息)。 * Three-state spec (§11.3 DoD):
* - loading: DetailPageSkeleton
* - error: errorNode local fallback + notify.error
* - empty: notFound (no matching course)
* *
* 三态规范§11.3 DoD * Privacy: student view does not show enrollment list (only course basic info).
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptynotFound无匹配课程
* *
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4 * Related: ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
*/ */
import { ArrowLeft, BookOpen } from "lucide-react"; import { ArrowLeft, BookOpen } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useElectiveCourses } from "@/lib/api"; import { useStudentElectiveDetail, type ElectiveCourse } from "@/lib/api";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { import {
DetailPageShell, DetailPageShell,
@@ -31,12 +33,53 @@ import {
DetailSection, DetailSection,
DetailField, DetailField,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/** 默认学期(与列表页一致,详情复用列表 hook */ /** Status badge variant mapping (@contract-pending, aligned with i18n status* keys) */
const DEFAULT_TERM_ID = "2026-spring"; function statusBadgeVariant(
status: ElectiveCourse["status"],
): "default" | "secondary" | "destructive" | "outline" {
switch (status) {
case "open":
return "default";
case "draft":
return "secondary";
case "closed":
return "outline";
case "cancelled":
return "destructive";
}
}
/** Status i18n key mapping */
function statusI18nKey(status: ElectiveCourse["status"]): string {
switch (status) {
case "open":
return "statusOpen";
case "draft":
return "statusDraft";
case "closed":
return "statusClosed";
case "cancelled":
return "statusCancelled";
}
}
/** Selection mode i18n key mapping */
function selectionModeI18nKey(mode: ElectiveCourse["selectionMode"]): string {
return mode === "fcfs" ? "selectionModeFcfs" : "selectionModeLottery";
}
/** Format ISO timestamp to locale string (client-only render, no SSR hydration risk) */
function formatIso(iso: string): string {
if (!iso) return "-";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString();
}
/** /**
* 选课详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * Elective detail client body. Must be wrapped in <Suspense> by server page.
*/ */
export function StudentElectiveDetailClient(): React.ReactElement { export function StudentElectiveDetailClient(): React.ReactElement {
const t = useTranslations("studentDomain.elective.detail"); const t = useTranslations("studentDomain.elective.detail");
@@ -44,10 +87,14 @@ export function StudentElectiveDetailClient(): React.ReactElement {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const courseId = params?.id ?? ""; const courseId = params?.id ?? "";
// @contract-pendingMSW 兜底(详情复用列表 hook客户端过滤 // @contract-pending: MSW fallback
const { data, loading, error } = useElectiveCourses(DEFAULT_TERM_ID); const { data: course, loading, error } = useStudentElectiveDetail(courseId);
const course = data?.find((c) => c.id === courseId); useEffect(() => {
if (error) {
notify.error(tCommon("error.loadFailed", { message: String(error) }));
}
}, [error, tCommon]);
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -59,7 +106,20 @@ export function StudentElectiveDetailClient(): React.ReactElement {
const emptyNode = const emptyNode =
!loading && !error && !course ? ( !loading && !error && !course ? (
<EmptyState icon={BookOpen} title={t("notFound")} /> <EmptyState
icon={BookOpen}
title={t("notFound")}
action={{
label: t("backToList"),
href: "/shell/student/elective",
}}
/>
) : undefined;
const statusBadge = course ? (
<Badge variant={statusBadgeVariant(course.status)}>
{t(statusI18nKey(course.status))}
</Badge>
) : undefined; ) : undefined;
return ( return (
@@ -67,28 +127,77 @@ export function StudentElectiveDetailClient(): React.ReactElement {
title={course?.name ?? t("title")} title={course?.name ?? t("title")}
description={t("sectionBasic")} description={t("sectionBasic")}
icon={<BookOpen className="size-6" />} icon={<BookOpen className="size-6" />}
actions={statusBadge}
loading={loading} loading={loading}
loadingNode={<DetailPageSkeleton />} loadingNode={<DetailPageSkeleton />}
errorNode={errorNode} errorNode={errorNode}
emptyNode={emptyNode} emptyNode={emptyNode}
> >
<Link <Button asChild variant="ghost" size="sm" className="w-fit">
href="/shell/student/elective" <Link href="/shell/student/elective">
className="inline-flex h-9 items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
{t("backToList")} {t("backToList")}
</Link> </Link>
{course ? ( </Button>
<DetailSection title={t("sectionBasic")}> {course ? <ElectiveDetailBody course={course} /> : null}
<DetailField label={t("fieldName")} value={course.name} />
<DetailField label={t("fieldTeacher")} value={course.teacher} />
<DetailField label={t("fieldCapacity")} value={course.capacity} />
<DetailField label={t("fieldEnrolled")} value={course.enrolled} />
<DetailField label={t("fieldSchedule")} value={course.schedule} />
<DetailField label={t("fieldCredits")} value={course.credits} />
</DetailSection>
) : null}
</DetailPageShell> </DetailPageShell>
); );
} }
/**
* Detail body (basic info + description + schedule).
*/
function ElectiveDetailBody({
course,
}: {
course: ElectiveCourse;
}): React.ReactElement {
const t = useTranslations("studentDomain.elective.detail");
return (
<>
<DetailSection title={t("sectionBasic")}>
<div className="grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
<DetailField label={t("fieldName")} value={course.name} />
<DetailField label={t("fieldTeacher")} value={course.teacher} />
<DetailField label={t("fieldCategory")} value={course.category} />
<DetailField label={t("fieldSubject")} value={course.subject} />
<DetailField label={t("fieldGrade")} value={course.grade} />
<DetailField label={t("fieldClassroom")} value={course.classroom} />
<DetailField label={t("fieldCredits")} value={course.credits} />
<DetailField label={t("fieldCapacity")} value={course.capacity} />
<DetailField label={t("fieldEnrolled")} value={course.enrolled} />
<DetailField
label={t("fieldSelectionMode")}
value={t(selectionModeI18nKey(course.selectionMode))}
/>
<DetailField label={t("fieldStartDate")} value={course.startDate} />
<DetailField label={t("fieldEndDate")} value={course.endDate} />
<DetailField
label={t("fieldSelectionStartAt")}
value={formatIso(course.selectionStartAt)}
/>
<DetailField
label={t("fieldSelectionEndAt")}
value={formatIso(course.selectionEndAt)}
/>
</div>
</DetailSection>
<DetailSection title={t("sectionDescription")}>
{course.description ? (
<p className="whitespace-pre-line text-sm leading-relaxed text-foreground">
{course.description}
</p>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</DetailSection>
<DetailSection title={t("sectionSchedule")}>
<p className="text-sm leading-relaxed text-foreground">
{course.schedule}
</p>
</DetailSection>
</>
);
}

View File

@@ -1,19 +1,25 @@
"use client"; "use client";
// @contract-pendingelectiveCourses schema 未实现,全 MSW 兜底 // @contract-pendingelectiveCourses / studentSelections schema 未实现,全 MSW 兜底
/** /**
* 学生选课列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3 * 学生选课列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3
* *
* 数据契约: * 数据契约:
* - electiveCourses(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - electiveCourses(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - enrollCourse mutation ❌ schema 无 Mutation 类型 → MSW 兜底 * - studentSelections(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - enrollCourse / dropCourse mutation ❌ schema 无 Mutation 类型 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-elective * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-elective
* *
* URL 状态:?q=客户端按名称过滤MSW 不支持服务端过滤) * URL 状态:?q=&status=&mode=(客户端按名称/状态/选课模式过滤)
* *
* 两分区结构: * 两分区结构Card 网格布局)
* - 我的选课(mock 空态,无独立 hook * - 我的选课(已选课程 Card 网格 + 退课操作 + 退课确认对话框
* - 可选课程(列表表格 + 选课操作 * - 可选课程(可选课程 Card 网格 + 选课操作 + 状态/选课模式徽章
*
* 筛选器:
* - 名称搜索FilterSearchInput
* - 状态筛选Select全部/开放中/已关闭/进行中/已结束)
* - 选课模式筛选Select全部/先到先得/抽签)
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
@@ -22,22 +28,65 @@
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useElectiveCourses, useEnrollCourse, type Course } from "@/lib/api"; import {
useDropCourse,
useElectiveCourses,
useEnrollCourse,
useStudentSelections,
type Course,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import {
FilterBar,
FilterSearchInput,
} from "@/shared/components/ui/filter-bar";
import { Select } from "@/shared/components/ui/select";
import { Textarea } from "@/shared/components/ui/textarea";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
/** 默认学期(无独立 hook 查询当前学期,前端固定值) */ /** 默认学期(无独立 hook 查询当前学期,前端固定值) */
const DEFAULT_TERM_ID = "2026-spring"; const DEFAULT_TERM_ID = "2026-spring";
/** 课程状态枚举(与 Course.status 对齐) */
type CourseStatus = NonNullable<Course["status"]>;
/** 选课模式枚举(与 Course.selectionMode 对齐) */
type SelectionMode = NonNullable<Course["selectionMode"]>;
/** 状态筛选选项 */
const STATUS_FILTER_OPTIONS: readonly { value: string; labelKey: string }[] = [
{ value: "", labelKey: "statusAll" },
{ value: "open", labelKey: "statusOpen" },
{ value: "closed", labelKey: "statusClosed" },
{ value: "in_progress", labelKey: "statusInProgress" },
{ value: "completed", labelKey: "statusCompleted" },
] as const;
/** 选课模式筛选选项 */
const MODE_FILTER_OPTIONS: readonly { value: string; labelKey: string }[] = [
{ value: "", labelKey: "modeAll" },
{ value: "fcfs", labelKey: "modeFcfs" },
{ value: "lottery", labelKey: "modeLottery" },
] as const;
/** /**
* 选课列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 选课列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。 * useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -50,17 +99,46 @@ export function StudentElectiveListClient(): React.ReactElement {
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const modeFilter = searchParams.get("mode") ?? "";
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useElectiveCourses(DEFAULT_TERM_ID); const {
data: availableData,
loading,
error,
} = useElectiveCourses(DEFAULT_TERM_ID);
const { data: selectionsData, loading: selectionsLoading } =
useStudentSelections(DEFAULT_TERM_ID);
const enrollMutation = useEnrollCourse(); const enrollMutation = useEnrollCourse();
const dropMutation = useDropCourse();
const items = data ?? []; // 退课对话框状态
const [dropTarget, setDropTarget] = useState<Course | null>(null);
const [dropReason, setDropReason] = useState<string>("");
const [dropDialogOpen, setDropDialogOpen] = useState<boolean>(false);
// 客户端按名称过滤MSW 不支持服务端过滤) const availableItems = availableData ?? [];
const filtered = q const selectionItems = selectionsData ?? [];
? items.filter((c) => c.name.toLowerCase().includes(q.toLowerCase()))
: items; // 客户端按名称/状态/模式过滤可选课程
const filteredAvailable = useMemo<Course[]>(() => {
return availableItems.filter((c) => {
if (q) {
const lower = q.toLowerCase();
if (
!c.name.toLowerCase().includes(lower) &&
!(c.subjectName ?? "").toLowerCase().includes(lower) &&
!(c.description ?? "").toLowerCase().includes(lower)
) {
return false;
}
}
if (statusFilter && c.status !== statusFilter) return false;
if (modeFilter && c.selectionMode !== modeFilter) return false;
return true;
});
}, [availableItems, q, statusFilter, modeFilter]);
const updateQuery = (key: string, value: string): void => { const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -85,6 +163,32 @@ export function StudentElectiveListClient(): React.ReactElement {
})(); })();
}; };
const openDropDialog = (course: Course): void => {
setDropTarget(course);
setDropReason("");
setDropDialogOpen(true);
};
const closeDropDialog = (): void => {
setDropDialogOpen(false);
setDropTarget(null);
setDropReason("");
};
const handleConfirmDrop = (): void => {
if (!dropTarget) return;
const courseId = dropTarget.id;
void (async (): Promise<void> => {
try {
await dropMutation.run(courseId, dropReason.trim() || undefined);
notify.success(t("dropSuccess"));
closeDropDialog();
} catch (err) {
notify.error(t("dropError", { message: String(err) }));
}
})();
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -94,14 +198,11 @@ export function StudentElectiveListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
const emptyNode = ( const emptyAvailableNode = (
<EmptyState <EmptyState
icon={BookOpen} icon={BookOpen}
title={t("emptyAvailable")} title={t("emptyAvailable")}
action={{ description={t("mswNotice")}
label: t("title"),
href: "/shell/student/elective",
}}
/> />
); );
@@ -111,55 +212,272 @@ export function StudentElectiveListClient(): React.ReactElement {
description={t("description")} description={t("description")}
icon={<BookOpen className="size-6" />} icon={<BookOpen className="size-6" />}
filters={ filters={
<FilterBar variant="wrap">
<FilterSearchInput <FilterSearchInput
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
value={q} value={q}
onChange={(v) => updateQuery("q", v)} onChange={(v) => updateQuery("q", v)}
/> />
<Select
value={statusFilter}
onValueChange={(v) => updateQuery("status", v)}
options={STATUS_FILTER_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey),
}))}
placeholder={t("filterByStatus")}
aria-label={t("filterByStatus")}
className="md:w-40"
/>
<Select
value={modeFilter}
onValueChange={(v) => updateQuery("mode", v)}
options={MODE_FILTER_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey),
}))}
placeholder={t("filterBySelectionMode")}
aria-label={t("filterBySelectionMode")}
className="md:w-40"
/>
</FilterBar>
} }
loading={loading} loading={loading && availableItems.length === 0}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
empty={filtered.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode} errorNode={errorNode}
pagination={ pagination={
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span className="text-xs">{t("mswNotice")}</span> <span className="text-xs">{t("mswNotice")}</span>
<span>{t("total", { count: filtered.length })}</span> <span>{t("total", { count: filteredAvailable.length })}</span>
</div> </div>
} }
> >
<div className="space-y-6"> <div className="space-y-8">
{/* 我的选课 - mock 空态(无独立 hook 查询已选课程) */} {/* 我的选课 - Card 网格 */}
<div className="rounded-xl border bg-card p-6"> <section className="space-y-3">
<h3 className="mb-3 text-lg font-semibold"> <div className="flex items-baseline justify-between">
<h3 className="text-lg font-semibold">
{t("sectionMySelections")} {t("sectionMySelections")}
</h3> </h3>
<span className="text-xs text-muted-foreground">
{t("total", { count: selectionItems.length })}
</span>
</div>
{selectionsLoading && selectionItems.length === 0 ? (
<ListPageSkeleton rows={2} />
) : selectionItems.length === 0 ? (
<div className="rounded-xl border bg-card p-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("emptyMySelections")} {t("emptyMySelections")}
</p> </p>
</div> </div>
) : (
<SelectionCardGrid
items={selectionItems}
dropping={dropMutation.loading}
onDrop={openDropDialog}
/>
)}
</section>
{/* 可选课程 */} {/* 可选课程 - Card 网格 */}
<div> <section className="space-y-3">
<h3 className="mb-3 text-lg font-semibold"> <div className="flex items-baseline justify-between">
<h3 className="text-lg font-semibold">
{t("sectionAvailableCourses")} {t("sectionAvailableCourses")}
</h3> </h3>
<ElectiveTable <span className="text-xs text-muted-foreground">
items={filtered} {t("total", { count: filteredAvailable.length })}
</span>
</div>
{filteredAvailable.length === 0 && !loading ? (
emptyAvailableNode
) : (
<AvailableCardGrid
items={filteredAvailable}
enrolling={enrollMutation.loading} enrolling={enrollMutation.loading}
onEnroll={handleEnroll} onEnroll={handleEnroll}
/> />
)}
</section>
</div> </div>
{/* 退课确认对话框 */}
<AlertDialog open={dropDialogOpen} onOpenChange={setDropDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("dropDialogTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("dropDialogDescription", {
courseName: dropTarget?.name ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<label
htmlFor="drop-reason"
className="text-sm font-medium text-foreground"
>
{t("dropDialogReasonLabel")}
</label>
<Textarea
id="drop-reason"
value={dropReason}
onChange={(e) => setDropReason(e.target.value)}
placeholder={t("dropDialogReasonPlaceholder")}
rows={3}
disabled={dropMutation.loading}
/>
</div> </div>
<AlertDialogFooter>
<AlertDialogCancel
onClick={closeDropDialog}
disabled={dropMutation.loading}
>
{t("dropDialogCancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDrop}
disabled={dropMutation.loading}
>
{dropMutation.loading
? t("dropDialogLoading")
: t("dropDialogConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ListPageShell> </ListPageShell>
); );
} }
/** /**
* 可选课程表格(纯展示组件,对齐 §8.2 排版规范)。 * 我的选课 Card 网格(小屏 1 列,桌面 2-3 列)。
*/ */
function ElectiveTable({ function SelectionCardGrid({
items,
dropping,
onDrop,
}: {
items: Course[];
dropping: boolean;
onDrop: (course: Course) => void;
}): React.ReactElement {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((course) => (
<SelectionCard
key={course.id}
course={course}
dropping={dropping}
onDrop={onDrop}
/>
))}
</div>
);
}
/**
* 我的选课 Card 单卡片。
*/
function SelectionCard({
course,
dropping,
onDrop,
}: {
course: Course;
dropping: boolean;
onDrop: (course: Course) => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.elective.list");
const isFull = course.enrolled >= course.capacity;
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<Link
href={`/shell/student/elective/${course.id}`}
className="line-clamp-2 font-medium hover:underline"
>
{course.name}
</Link>
{course.subjectName ? (
<p className="text-xs text-muted-foreground">
{t("fieldSubject")}: {course.subjectName}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{course.status ? <StatusBadge status={course.status} /> : null}
{course.selectionMode ? (
<SelectionModeBadge mode={course.selectionMode} />
) : null}
</div>
</div>
{course.description ? (
<p className="line-clamp-2 text-xs text-muted-foreground">
{course.description}
</p>
) : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
<div>
<span className="font-medium text-foreground">{t("colTeacher")}</span>
: {course.teacher}
</div>
<div>
<span className="font-medium text-foreground">
{t("fieldCredits")}
</span>
: {course.credits}
</div>
<div className="col-span-2">
<span className="font-medium text-foreground">
{t("fieldSchedule")}
</span>
: {course.schedule}
</div>
<div>
<span className="font-medium text-foreground">
{t("fieldCapacity")}
</span>
:{" "}
{t("enrolledCount", {
enrolled: course.enrolled,
capacity: course.capacity,
})}
{isFull ? (
<span className="ml-1 text-destructive">·{t("capacityFull")}</span>
) : null}
</div>
<div>
<span className="font-medium text-foreground">
{t("fieldCategory")}
</span>
: {course.category}
</div>
</div>
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
<Button
type="button"
size="sm"
variant="destructive"
disabled={dropping}
onClick={() => onDrop(course)}
>
{t("drop")}
</Button>
</div>
</div>
);
}
/**
* 可选课程 Card 网格。
*/
function AvailableCardGrid({
items, items,
enrolling, enrolling,
onEnroll, onEnroll,
@@ -168,56 +486,187 @@ function ElectiveTable({
enrolling: boolean; enrolling: boolean;
onEnroll: (courseId: string) => void; onEnroll: (courseId: string) => void;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.elective.list");
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<table className="w-full text-sm"> {items.map((course) => (
<thead className="border-b bg-muted/30"> <AvailableCard
<tr> key={course.id}
<th className="p-3 text-left font-medium">{t("colCourseName")}</th> course={course}
<th className="p-3 text-left font-medium">{t("colTeacher")}</th> enrolling={enrolling}
<th className="p-3 text-left font-medium">{t("colCapacity")}</th> onEnroll={onEnroll}
<th className="p-3 text-left font-medium">{t("colEnrolled")}</th> />
<th className="p-3 text-left font-medium">{t("colSchedule")}</th> ))}
<th className="p-3 text-left font-medium">{t("colCredits")}</th>
<th className="p-3 text-left font-medium">{t("colCategory")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((course) => {
const isFull = course.enrolled >= course.capacity;
return (
<tr key={course.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/student/elective/${course.id}`}
className="font-medium hover:underline"
>
{course.name}
</Link>
</td>
<td className="p-3 text-muted-foreground">{course.teacher}</td>
<td className="p-3 text-muted-foreground">{course.capacity}</td>
<td className="p-3 text-muted-foreground">{course.enrolled}</td>
<td className="p-3 text-muted-foreground">{course.schedule}</td>
<td className="p-3 text-muted-foreground">{course.credits}</td>
<td className="p-3 text-muted-foreground">{course.category}</td>
<td className="p-3 text-right">
<Button
type="button"
size="sm"
disabled={enrolling || isFull}
onClick={() => onEnroll(course.id)}
>
{t("enroll")}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
); );
} }
/**
* 可选课程 Card 单卡片。
*/
function AvailableCard({
course,
enrolling,
onEnroll,
}: {
course: Course;
enrolling: boolean;
onEnroll: (courseId: string) => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.elective.list");
const isFull = course.enrolled >= course.capacity;
const isClosed = course.status === "closed";
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<Link
href={`/shell/student/elective/${course.id}`}
className="line-clamp-2 font-medium hover:underline"
>
{course.name}
</Link>
{course.subjectName ? (
<p className="text-xs text-muted-foreground">
{t("fieldSubject")}: {course.subjectName}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{course.status ? <StatusBadge status={course.status} /> : null}
{course.selectionMode ? (
<SelectionModeBadge mode={course.selectionMode} />
) : null}
</div>
</div>
{course.description ? (
<p className="line-clamp-2 text-xs text-muted-foreground">
{course.description}
</p>
) : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
<div>
<span className="font-medium text-foreground">{t("colTeacher")}</span>
: {course.teacher}
</div>
<div>
<span className="font-medium text-foreground">
{t("fieldCredits")}
</span>
: {course.credits}
</div>
<div className="col-span-2">
<span className="font-medium text-foreground">
{t("fieldSchedule")}
</span>
: {course.schedule}
</div>
<div>
<span className="font-medium text-foreground">
{t("fieldCapacity")}
</span>
:{" "}
{t("enrolledCount", {
enrolled: course.enrolled,
capacity: course.capacity,
})}
{isFull ? (
<span className="ml-1 text-destructive">·{t("capacityFull")}</span>
) : null}
</div>
<div>
<span className="font-medium text-foreground">
{t("fieldCategory")}
</span>
: {course.category}
</div>
</div>
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
<Button
type="button"
size="sm"
disabled={enrolling || isFull || isClosed}
onClick={() => onEnroll(course.id)}
>
{isClosed
? t("statusClosed")
: isFull
? t("capacityFull")
: t("enroll")}
</Button>
</div>
</div>
);
}
/**
* 课程状态徽章(按状态色阶展示)。
*/
function StatusBadge({ status }: { status: CourseStatus }): React.ReactElement {
const t = useTranslations("studentDomain.elective.list");
const labelKey =
status === "open"
? "badgeStatusOpen"
: status === "closed"
? "badgeStatusClosed"
: status === "in_progress"
? "badgeStatusInProgress"
: "badgeStatusCompleted";
const cls = statusToBadgeClass(status);
return (
<span
className={
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
cls
}
>
{t(labelKey)}
</span>
);
}
/**
* 选课模式徽章。
*/
function SelectionModeBadge({
mode,
}: {
mode: SelectionMode;
}): React.ReactElement {
const t = useTranslations("studentDomain.elective.list");
const labelKey = mode === "fcfs" ? "badgeModeFcfs" : "badgeModeLottery";
const cls =
mode === "fcfs"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "bg-purple-500/10 text-purple-600 dark:text-purple-400";
return (
<span
className={
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
cls
}
>
{t(labelKey)}
</span>
);
}
/**
* 状态 → 徽章色阶映射(对齐 §3.10 设计令牌规范)。
*/
function statusToBadgeClass(status: CourseStatus): string {
switch (status) {
case "open":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "closed":
return "bg-muted text-muted-foreground";
case "in_progress":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "completed":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}

View File

@@ -1,20 +1,18 @@
"use client"; "use client";
/** /**
* 学生错题本页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2 * 学生错题本页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
* *
* 数据契约 * 视图结构
* - studentErrorBook(q, status, source, dueOnly):❌ schema 无此字段 → MSW 兜底(@contract-pending * 1. 5 列统计卡total / new / learning / mastered+掌握率 / toReview 高亮逾期
* - markErrorMastered(id):✅ 已存在(来自 student.ts * 2. 筛选器keyword / status / source / dueOnly / reset+ 视图切换card / table
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-error-book-v2 * 3. Card 网格(默认)或 Table切换
* 4. 详情弹窗ErrorBookDetailDialog+ 变式练习跳转
* *
* URL 状态:?q=xxx &status=xxx &source=xxx &dueOnly=1 * URL 状态:?q=xxx &status=xxx &source=xxx &dueOnly=1
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { BookX } from "lucide-react"; import { BookX, LayoutGrid, List, RotateCcw } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState, useTransition } from "react"; import { useCallback, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -29,21 +27,28 @@ import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { StatCard } from "@/shared/components/ui/stat-card"; import { StatCard } from "@/shared/components/ui/stat-card";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { ErrorBookDetailDialog } from "./error-book-detail-dialog";
/** 错题状态枚举(@contract-pending与 i18n status* key 对齐) */ /** 错题状态枚举(@contract-pending与 i18n status* key 对齐) */
const STATUS_OPTIONS = ["new", "learning", "mastered", "to_review"] as const; const STATUS_OPTIONS = [
"new",
"learning",
"mastered",
"to_review",
"archived",
] as const;
/** 错题来源枚举(@contract-pending待后端契约补齐后改为动态拉取 */ /** 错题来源枚举(与 i18n source* key 对齐 */
const SOURCE_OPTIONS = ["exam", "homework", "practice"] as const; const SOURCE_OPTIONS = ["exam", "homework", "practice", "manual"] as const;
/** 视图模式 */
type ViewMode = "card" | "table";
/**
* 错题本客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function StudentErrorBookListClient(): React.ReactElement { export function StudentErrorBookListClient(): React.ReactElement {
const t = useTranslations("studentDomain.errorBook"); const t = useTranslations("studentDomain.errorBook");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@@ -56,10 +61,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
const source = searchParams.get("source") ?? ""; const source = searchParams.get("source") ?? "";
const dueOnly = searchParams.get("dueOnly") === "1"; const dueOnly = searchParams.get("dueOnly") === "1";
// 已掌握的本地乐观更新集合
const [masteredIds, setMasteredIds] = useState<Record<string, boolean>>({}); const [masteredIds, setMasteredIds] = useState<Record<string, boolean>>({});
const [viewMode, setViewMode] = useState<ViewMode>("card");
const [selectedItem, setSelectedItem] = useState<StudentErrorBookItem | null>(
null,
);
// @contract-pendingMSW 兜底 // @contract-pending: MSW fallback
const { data, loading, error } = useStudentErrorBookV2({ const { data, loading, error } = useStudentErrorBookV2({
q: q || undefined, q: q || undefined,
status: status || undefined, status: status || undefined,
@@ -76,6 +84,8 @@ export function StudentErrorBookListClient(): React.ReactElement {
return items.filter((item) => !masteredIds[item.id]); return items.filter((item) => !masteredIds[item.id]);
}, [items, masteredIds]); }, [items, masteredIds]);
const hasActiveFilters = Boolean(q || status || source || dueOnly);
const updateQuery = (key: string, value: string): void => { const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
if (value) { if (value) {
@@ -84,7 +94,7 @@ export function StudentErrorBookListClient(): React.ReactElement {
params.delete(key); params.delete(key);
} }
startTransition(() => { startTransition(() => {
router.push(`/shell/student/error-book?${params.toString()}`); router.push("/shell/student/error-book?" + params.toString());
}); });
}; };
@@ -96,7 +106,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
params.delete("dueOnly"); params.delete("dueOnly");
} }
startTransition(() => { startTransition(() => {
router.push(`/shell/student/error-book?${params.toString()}`); router.push("/shell/student/error-book?" + params.toString());
});
};
const handleReset = (): void => {
startTransition(() => {
router.push("/shell/student/error-book");
}); });
}; };
@@ -108,13 +124,16 @@ export function StudentErrorBookListClient(): React.ReactElement {
notify.success(t("markSuccess")); notify.success(t("markSuccess"));
} catch (err) { } catch (err) {
notify.error(t("markError")); notify.error(t("markError"));
// 保留错误日志便于诊断(不影响用户)
console.error("[student.error-book] markMastered failed:", err); console.error("[student.error-book] markMastered failed:", err);
} }
}, },
[markMastered, t], [markMastered, t],
); );
const handleViewDetail = useCallback((item: StudentErrorBookItem): void => {
setSelectedItem(item);
}, []);
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -123,7 +142,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
const emptyNode = <EmptyState icon={BookX} title={t("emptyTitle")} />; const emptyNode = (
<EmptyState
icon={BookX}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
);
return ( return (
<ListPageShell <ListPageShell
@@ -144,14 +169,11 @@ export function StudentErrorBookListClient(): React.ReactElement {
aria-label={t("statusFilter")} aria-label={t("statusFilter")}
> >
<option value="">{t("allStatus")}</option> <option value="">{t("allStatus")}</option>
{STATUS_OPTIONS.map((s) => { {STATUS_OPTIONS.map((s) => (
const labelKey = statusOptionLabelKey(s);
return (
<option key={s} value={s}> <option key={s} value={s}>
{t(labelKey)} {t(statusOptionLabelKey(s))}
</option> </option>
); ))}
})}
</select> </select>
<select <select
value={source} value={source}
@@ -162,7 +184,7 @@ export function StudentErrorBookListClient(): React.ReactElement {
<option value="">{t("allSources")}</option> <option value="">{t("allSources")}</option>
{SOURCE_OPTIONS.map((src) => ( {SOURCE_OPTIONS.map((src) => (
<option key={src} value={src}> <option key={src} value={src}>
{src} {t(sourceOptionLabelKey(src))}
</option> </option>
))} ))}
</select> </select>
@@ -175,6 +197,46 @@ export function StudentErrorBookListClient(): React.ReactElement {
/> />
{t("dueOnlyFilter")} {t("dueOnlyFilter")}
</label> </label>
{hasActiveFilters ? (
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-9"
>
<RotateCcw className="mr-1 size-3" />
{t("resetFilters")}
</Button>
) : null}
<div className="ml-auto flex items-center gap-1 rounded-md border border-input p-0.5">
<button
type="button"
aria-label={t("viewCard")}
onClick={() => setViewMode("card")}
className={
"inline-flex h-8 items-center rounded px-2 text-xs " +
(viewMode === "card"
? "bg-primary text-primary-foreground"
: "text-muted-foreground")
}
>
<LayoutGrid className="size-3.5" />
</button>
<button
type="button"
aria-label={t("viewTable")}
onClick={() => setViewMode("table")}
className={
"inline-flex h-8 items-center rounded px-2 text-xs " +
(viewMode === "table"
? "bg-primary text-primary-foreground"
: "text-muted-foreground")
}
>
<List className="size-3.5" />
</button>
</div>
</> </>
} }
loading={loading} loading={loading}
@@ -189,16 +251,33 @@ export function StudentErrorBookListClient(): React.ReactElement {
} }
> >
{stats ? <StatsSection stats={stats} /> : null} {stats ? <StatsSection stats={stats} /> : null}
{viewMode === "card" ? (
<ErrorBookCardGrid
items={visibleItems}
onMarkMastered={handleMarkMastered}
onViewDetail={handleViewDetail}
marking={marking}
/>
) : (
<ErrorBookTable <ErrorBookTable
items={visibleItems} items={visibleItems}
onMarkMastered={handleMarkMastered} onMarkMastered={handleMarkMastered}
onViewDetail={handleViewDetail}
marking={marking}
/>
)}
<ErrorBookDetailDialog
item={selectedItem}
onOpenChange={(open) => {
if (!open) setSelectedItem(null);
}}
onMarkMastered={handleMarkMastered}
marking={marking} marking={marking}
/> />
</ListPageShell> </ListPageShell>
); );
} }
/** 错题状态枚举到 i18n key 的映射。 */
function statusOptionLabelKey(status: string): string { function statusOptionLabelKey(status: string): string {
switch (status) { switch (status) {
case "new": case "new":
@@ -209,12 +288,84 @@ function statusOptionLabelKey(status: string): string {
return "statusMastered"; return "statusMastered";
case "to_review": case "to_review":
return "statusToReview"; return "statusToReview";
case "archived":
return "statusArchived";
default: default:
return "allStatus"; return "allStatus";
} }
} }
/** 统计卡片区。 */ function sourceOptionLabelKey(source: string): string {
switch (source) {
case "exam":
return "sourceExam";
case "homework":
return "sourceHomework";
case "practice":
return "sourcePractice";
case "manual":
return "sourceManual";
default:
return "allSources";
}
}
function sourceOptionLabel(source: string, t: (key: string) => string): string {
const key = sourceOptionLabelKey(source);
return key === "allSources" ? source : t(key);
}
function errorBookStatusToBadgeClass(status: string): string {
switch (status) {
case "new":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "learning":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "mastered":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "to_review":
return "bg-destructive/10 text-destructive";
case "archived":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
function difficultyToBadgeClass(
difficulty: "easy" | "medium" | "hard",
): string {
switch (difficulty) {
case "easy":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "medium":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "hard":
return "bg-destructive/10 text-destructive";
default:
return "bg-muted text-muted-foreground";
}
}
function formatRateToPercent(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return (rate * 100).toFixed(0);
}
function formatDate(isoDate: string): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN");
}
function isOverdue(nextReviewAt?: string): boolean {
if (!nextReviewAt) return false;
const d = new Date(nextReviewAt);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() < Date.now();
}
function StatsSection({ function StatsSection({
stats, stats,
}: { }: {
@@ -222,32 +373,238 @@ function StatsSection({
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.errorBook"); const t = useTranslations("studentDomain.errorBook");
return ( return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <StatsGrid columns={5}>
<StatCard title={t("fieldTotal")} value={String(stats.total)} />
<StatCard title={t("fieldNew")} value={String(stats.new)} />
<StatCard title={t("fieldLearning")} value={String(stats.learning)} />
<StatCard title={t("fieldMastered")} value={String(stats.mastered)} />
<StatCard <StatCard
title={t("fieldMasteredRate")} title={t("fieldTotal")}
value={formatRate(stats.masteredRate)} value={String(stats.total)}
description={t("fieldTotalDesc")}
/>
<StatCard
title={t("fieldNew")}
value={String(stats.new)}
description={t("fieldNewDesc")}
/>
<StatCard
title={t("fieldLearning")}
value={String(stats.learning)}
description={t("fieldLearningDesc")}
/>
<StatCard
title={t("fieldMastered")}
value={String(stats.mastered)}
description={t("fieldMasteredDesc", {
rate: formatRateToPercent(stats.masteredRate),
})}
/> />
<StatCard <StatCard
title={t("fieldToReview")} title={t("fieldToReview")}
value={String(stats.toReview)} value={String(stats.toReview)}
description={t("fieldToReviewDesc")}
highlight={stats.toReview > 0} highlight={stats.toReview > 0}
/> />
</StatsGrid>
);
}
function ErrorBookStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const labelKey = statusOptionLabelKey(status);
const label = labelKey === "allStatus" ? status : t(labelKey);
const cls = errorBookStatusToBadgeClass(status);
return (
<span
className={
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
cls
}
>
{label}
</span>
);
}
function DifficultyBadge({
difficulty,
}: {
difficulty: "easy" | "medium" | "hard";
}): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const labelKey =
difficulty === "easy"
? "difficultyEasy"
: difficulty === "medium"
? "difficultyMedium"
: "difficultyHard";
const cls = difficultyToBadgeClass(difficulty);
return (
<span
className={
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
cls
}
>
{t(labelKey)}
</span>
);
}
function MasteryProgressBar({ level }: { level?: number }): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const safeLevel = level ?? 0;
const clamped = Math.max(0, Math.min(5, safeLevel));
const percent = (clamped / 5) * 100;
return (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: percent + "%" }}
/>
</div>
<span className="text-xs text-muted-foreground">{clamped}/5</span>
<span className="sr-only">{t("masteryLevel")}</span>
</div> </div>
); );
} }
/** 错题列表表格。 */ function ErrorBookCardGrid({
function ErrorBookTable({
items, items,
onMarkMastered, onMarkMastered,
onViewDetail,
marking, marking,
}: { }: {
items: StudentErrorBookItem[]; items: StudentErrorBookItem[];
onMarkMastered: (id: string) => Promise<void>; onMarkMastered: (id: string) => Promise<void>;
onViewDetail: (item: StudentErrorBookItem) => void;
marking: boolean;
}): React.ReactElement {
if (items.length === 0) return <></>;
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{items.map((item) => (
<ErrorBookCard
key={item.id}
item={item}
onMarkMastered={onMarkMastered}
onViewDetail={onViewDetail}
marking={marking}
/>
))}
</div>
);
}
function ErrorBookCard({
item,
onMarkMastered,
onViewDetail,
marking,
}: {
item: StudentErrorBookItem;
onMarkMastered: (id: string) => Promise<void>;
onViewDetail: (item: StudentErrorBookItem) => void;
marking: boolean;
}): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const overdue = isOverdue(item.nextReviewAt);
const cardBorder = overdue ? "border-destructive" : "border";
return (
<div
className={
"flex flex-col gap-3 rounded-xl " +
cardBorder +
" bg-card p-4 shadow-sm"
}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="line-clamp-2 text-sm font-medium">{item.question}</p>
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{item.subject}</span>
<span aria-hidden="true">/</span>
<span>{sourceOptionLabel(item.source, t)}</span>
</div>
</div>
<ErrorBookStatusBadge status={item.status} />
</div>
<div className="flex flex-wrap items-center gap-2">
{item.difficulty ? (
<DifficultyBadge difficulty={item.difficulty} />
) : null}
{(item.errorTags ?? []).map((tag) => (
<span
key={tag}
className="inline-flex h-5 items-center rounded border px-1.5 text-xs text-muted-foreground"
>
{tag}
</span>
))}
{item.note ? (
<span
title={item.note}
className="inline-flex h-5 items-center text-xs text-muted-foreground"
>
{"\u270E"}
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<MasteryProgressBar level={item.masteryLevel} />
<span className="text-muted-foreground">
{t("reviewCount")}: {item.reviewCount ?? 0}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>
{t("colLastErrorAt")}: {formatDate(item.lastErrorAt)}
</span>
{item.nextReviewAt ? (
<span className={overdue ? "font-medium text-destructive" : ""}>
{t("nextReviewAt")}: {formatDate(item.nextReviewAt)}
{overdue ? " (" + t("overdue") + ")" : ""}
</span>
) : null}
</div>
<div className="flex items-center justify-end gap-2 border-t pt-2">
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => onViewDetail(item)}
>
{t("viewDetail")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={marking || item.status === "mastered"}
onClick={() => void onMarkMastered(item.id)}
>
{t("markMastered")}
</Button>
</div>
</div>
);
}
function ErrorBookTable({
items,
onMarkMastered,
onViewDetail,
marking,
}: {
items: StudentErrorBookItem[];
onMarkMastered: (id: string) => Promise<void>;
onViewDetail: (item: StudentErrorBookItem) => void;
marking: boolean; marking: boolean;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.errorBook"); const t = useTranslations("studentDomain.errorBook");
@@ -263,6 +620,7 @@ function ErrorBookTable({
<th className="p-3 text-left font-medium">{t("colLastErrorAt")}</th> <th className="p-3 text-left font-medium">{t("colLastErrorAt")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th> <th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-left font-medium">{t("colSource")}</th> <th className="p-3 text-left font-medium">{t("colSource")}</th>
<th className="p-3 text-left font-medium">{t("masteryLevel")}</th>
<th className="p-3 text-left font-medium">{t("colActions")}</th> <th className="p-3 text-left font-medium">{t("colActions")}</th>
</tr> </tr>
</thead> </thead>
@@ -270,15 +628,19 @@ function ErrorBookTable({
{items.map((item) => ( {items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30"> <tr key={item.id} className="hover:bg-muted/30">
<td className="p-3"> <td className="p-3">
<p className="font-medium line-clamp-2 max-w-md"> <button
type="button"
className="max-w-md text-left font-medium line-clamp-2 hover:underline"
onClick={() => onViewDetail(item)}
>
{item.question} {item.question}
</p> </button>
</td> </td>
<td className="p-3 text-muted-foreground">{item.subject}</td> <td className="p-3 text-muted-foreground">{item.subject}</td>
<td className="p-3"> <td className="p-3">
<span <span
className={ className={
item.errorCount >= 3 ? "text-destructive font-semibold" : "" item.errorCount >= 3 ? "font-semibold text-destructive" : ""
} }
> >
{item.errorCount} {item.errorCount}
@@ -291,9 +653,21 @@ function ErrorBookTable({
<ErrorBookStatusBadge status={item.status} /> <ErrorBookStatusBadge status={item.status} />
</td> </td>
<td className="p-3 text-xs text-muted-foreground"> <td className="p-3 text-xs text-muted-foreground">
{item.source} {sourceOptionLabel(item.source, t)}
</td> </td>
<td className="p-3"> <td className="p-3">
<MasteryProgressBar level={item.masteryLevel} />
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => onViewDetail(item)}
>
{t("viewDetail")}
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -303,6 +677,7 @@ function ErrorBookTable({
> >
{t("markMastered")} {t("markMastered")}
</Button> </Button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -311,52 +686,3 @@ function ErrorBookTable({
</div> </div>
); );
} }
/** 错题状态徽章。 */
function ErrorBookStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const labelKey = statusOptionLabelKey(status);
const label = labelKey === "allStatus" ? status : t(labelKey);
const cls = errorBookStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
/** 根据错题状态返回 Tailwind 徽章类名。 */
function errorBookStatusToBadgeClass(status: string): string {
switch (status) {
case "new":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "learning":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "mastered":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "to_review":
return "bg-destructive/10 text-destructive";
default:
return "bg-muted text-muted-foreground";
}
}
/** 格式化 0-1 的掌握率为百分比字符串。 */
function formatRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/** 格式化 ISO 日期字符串为本地化展示。 */
function formatDate(isoDate: string): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN");
}

View File

@@ -2,27 +2,15 @@
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md // 契约工单docs/architecture/issues/contracts/core-edu_contract.md
"use client"; "use client";
/** import {
* 学生考试作答工作台 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2 CheckCircle2,
* ChevronLeft,
* 数据契约(@contract-pending 全 MSW ChevronRight,
* - studentExamTake(id) ❌ schema 无此根字段 → MSW 兜底 ClipboardCheck,
* - submitStudentExam(id, answers) mutation ❌ → MSW 兜底 Flag,
* Timer,
* 三栏布局WorkbenchPageShell } from "lucide-react";
* - left题目导航题号 / 已答 / 未答 / 已标记 状态) import Link from "next/link";
* - center当前题目作答区单选 / 多选 / 填空 / 简答)
* - right倒计时 + 提交按钮 + 作答统计
*
* 答案本地暂存localStorage key `exam-answers-${id}`,防刷新丢失
* 倒计时:基于 duration分钟到 0 自动提交
* 提交确认window.confirm 二次确认
*
* 三态规范§11.3 DoDloading / error / notFound
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ChevronLeft, ChevronRight, ClipboardCheck, Flag } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -50,7 +38,9 @@ interface PersistedState {
} }
/** /**
* 作答工作台客户端主体。需由 server page 包裹在 <Suspense> 中 * 学生考试作答工作台ARCHITECTURE.md §7.3 / §10 P2
*
* 三栏布局 + 倒计时自动提交 + localStorage 暂存 + ConfirmDialog 二次确认。
*/ */
export function StudentExamTakeClient(): React.ReactElement { export function StudentExamTakeClient(): React.ReactElement {
const t = useTranslations("studentDomain.exams.take"); const t = useTranslations("studentDomain.exams.take");
@@ -59,7 +49,6 @@ export function StudentExamTakeClient(): React.ReactElement {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const examId = params?.id ?? ""; const examId = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useStudentExamTake(examId); const { data, loading, error } = useStudentExamTake(examId);
const submitMutation = useSubmitStudentExam(); const submitMutation = useSubmitStudentExam();
@@ -68,17 +57,19 @@ export function StudentExamTakeClient(): React.ReactElement {
const [currentNo, setCurrentNo] = useState<number>(1); const [currentNo, setCurrentNo] = useState<number>(1);
const [secondsLeft, setSecondsLeft] = useState<number>(0); const [secondsLeft, setSecondsLeft] = useState<number>(0);
const [hydrated, setHydrated] = useState<boolean>(false); const [hydrated, setHydrated] = useState<boolean>(false);
const [confirmOpen, setConfirmOpen] = useState<boolean>(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<
"idle" | "saving" | "saved"
>("idle");
const storageKey = `${STORAGE_KEY_PREFIX}${examId}`; const storageKey = `${STORAGE_KEY_PREFIX}${examId}`;
const submittedRef = useRef<boolean>(false); const submittedRef = useRef<boolean>(false);
// 首次拿到数据时初始化倒计时
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
setSecondsLeft((prev) => (prev > 0 ? prev : data.duration * 60)); setSecondsLeft((prev) => (prev > 0 ? prev : data.duration * 60));
}, [data]); }, [data]);
// 从 localStorage 恢复暂存答案(仅客户端执行)
useEffect(() => { useEffect(() => {
if (!examId) return; if (!examId) return;
try { try {
@@ -88,21 +79,28 @@ export function StudentExamTakeClient(): React.ReactElement {
setAnswers(parsed.answers ?? {}); setAnswers(parsed.answers ?? {});
setMarked(parsed.marked ?? []); setMarked(parsed.marked ?? []);
} }
} catch { } catch (err) {
// localStorage 不可用或 JSON 损坏:忽略,从空开始 console.warn("[portal-shell] restore exam answers failed:", err);
} }
setHydrated(true); setHydrated(true);
}, [examId, storageKey]); }, [examId, storageKey]);
// 答案变化时写入 localStorage防刷新丢失
useEffect(() => { useEffect(() => {
if (!hydrated) return; if (!hydrated) return;
setAutoSaveStatus("saving");
const timer = window.setTimeout(() => {
try { try {
const payload: PersistedState = { answers, marked }; const payload: PersistedState = { answers, marked };
window.localStorage.setItem(storageKey, JSON.stringify(payload)); window.localStorage.setItem(storageKey, JSON.stringify(payload));
} catch { setAutoSaveStatus("saved");
// 写入失败:静默忽略,不影响作答 } catch (err) {
console.warn("[portal-shell] save exam answers failed:", err);
setAutoSaveStatus("idle");
} }
}, 400);
return (): void => {
window.clearTimeout(timer);
};
}, [answers, marked, hydrated, storageKey]); }, [answers, marked, hydrated, storageKey]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -110,8 +108,7 @@ export function StudentExamTakeClient(): React.ReactElement {
if (submittedRef.current) return; if (submittedRef.current) return;
if (!data) return; if (!data) return;
if (!isAuto) { if (!isAuto) {
const confirmed = window.confirm(t("submitConfirm")); setConfirmOpen(false);
if (!confirmed) return;
} }
submittedRef.current = true; submittedRef.current = true;
try { try {
@@ -119,8 +116,8 @@ export function StudentExamTakeClient(): React.ReactElement {
notify.success(t("submitSuccess")); notify.success(t("submitSuccess"));
try { try {
window.localStorage.removeItem(storageKey); window.localStorage.removeItem(storageKey);
} catch { } catch (err) {
// 清理失败不影响流程 console.warn("[portal-shell] clear exam answers failed:", err);
} }
router.push(`/shell/student/exams/${examId}/result`); router.push(`/shell/student/exams/${examId}/result`);
} catch (err) { } catch (err) {
@@ -132,7 +129,6 @@ export function StudentExamTakeClient(): React.ReactElement {
[answers, data, examId, router, storageKey, submitMutation, t], [answers, data, examId, router, storageKey, submitMutation, t],
); );
// 倒计时:每秒递减,归零自动提交
useEffect(() => { useEffect(() => {
if (!data || secondsLeft <= 0) return; if (!data || secondsLeft <= 0) return;
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -140,6 +136,7 @@ export function StudentExamTakeClient(): React.ReactElement {
if (prev <= 1) { if (prev <= 1) {
window.clearInterval(timer); window.clearInterval(timer);
if (!submittedRef.current) { if (!submittedRef.current) {
notify.warning(t("timeUpAutoSubmit"));
void handleSubmit(true); void handleSubmit(true);
} }
return 0; return 0;
@@ -150,7 +147,7 @@ export function StudentExamTakeClient(): React.ReactElement {
return (): void => { return (): void => {
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [data, secondsLeft, handleSubmit]); }, [data, secondsLeft, handleSubmit, t]);
const questions = data?.questions ?? []; const questions = data?.questions ?? [];
@@ -158,12 +155,11 @@ export function StudentExamTakeClient(): React.ReactElement {
const answeredCount = questions.filter( const answeredCount = questions.filter(
(q) => answers[String(q.questionNo)]?.trim().length, (q) => answers[String(q.questionNo)]?.trim().length,
).length; ).length;
const markedCount = marked.length;
return { return {
total: questions.length, total: questions.length,
answered: answeredCount, answered: answeredCount,
unanswered: questions.length - answeredCount, unanswered: questions.length - answeredCount,
marked: markedCount, marked: marked.length,
}; };
}, [questions, answers, marked]); }, [questions, answers, marked]);
@@ -185,9 +181,21 @@ export function StudentExamTakeClient(): React.ReactElement {
const currentQuestion = questions.find((q) => q.questionNo === currentNo); const currentQuestion = questions.find((q) => q.questionNo === currentNo);
return ( return (
<>
<WorkbenchPageShell <WorkbenchPageShell
title={data?.title ?? t("title")} title={data?.title ?? t("title")}
description={
data ? `${t("totalScore")}: ${data.totalScore}` : undefined
}
icon={<ClipboardCheck className="size-6" />} icon={<ClipboardCheck className="size-6" />}
actions={
<Button asChild variant="ghost" size="sm">
<Link href="/shell/student/exams">
<ChevronLeft className="mr-1 size-4" />
{t("back")}
</Link>
</Button>
}
loading={loading} loading={loading}
loadingNode={<WorkbenchPageSkeleton />} loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode ?? notFoundNode} errorNode={errorNode ?? notFoundNode}
@@ -240,22 +248,24 @@ export function StudentExamTakeClient(): React.ReactElement {
secondsLeft={secondsLeft} secondsLeft={secondsLeft}
stats={stats} stats={stats}
submitting={submitMutation.loading} submitting={submitMutation.loading}
onSubmit={() => void handleSubmit(false)} autoSaveStatus={autoSaveStatus}
onSubmit={() => setConfirmOpen(true)}
/> />
) : null ) : null
} }
/> />
{confirmOpen ? (
<ConfirmDialog
unansweredCount={stats.unanswered}
isBusy={submitMutation.loading}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void handleSubmit(false)}
/>
) : null}
</>
); );
} }
/**
* 左栏:题目导航面板。
*
* 题号网格,每格显示状态:
* - 当前题:边框高亮
* - 已答:背景填充
* - 已标记:右上角小标
*/
function QuestionNavPanel({ function QuestionNavPanel({
questions, questions,
currentNo, currentNo,
@@ -271,12 +281,15 @@ function QuestionNavPanel({
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.exams.take"); const t = useTranslations("studentDomain.exams.take");
const markedSet = new Set(marked); const markedSet = new Set(marked);
const answeredCount = questions.filter(
(q) => (answers[String(q.questionNo)] ?? "").trim().length > 0,
).length;
return ( return (
<WorkbenchPanel <WorkbenchPanel
title={t("questionNav")} title={t("questionNav")}
actions={ actions={
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t("answered")} {Object.keys(answers).length} / {questions.length} {t("answered")} {answeredCount} / {questions.length}
</span> </span>
} }
> >
@@ -297,7 +310,7 @@ function QuestionNavPanel({
type="button" type="button"
onClick={() => onSelect(q.questionNo)} onClick={() => onSelect(q.questionNo)}
className={`relative h-9 rounded-md border text-sm transition-colors hover:bg-accent ${cls}`} className={`relative h-9 rounded-md border text-sm transition-colors hover:bg-accent ${cls}`}
aria-label={`Question ${q.questionNo}`} aria-label={`${t("jumpToQuestion")} ${q.questionNo}`}
aria-current={isCurrent ? "true" : undefined} aria-current={isCurrent ? "true" : undefined}
> >
{q.questionNo} {q.questionNo}
@@ -315,11 +328,6 @@ function QuestionNavPanel({
); );
} }
/**
* 中栏:当前题目作答面板。
*
* 含:题号 / 题干 / 分值 / 答题区(按题型) / 上一题/下一题 / 标记按钮。
*/
function QuestionAnswerPanel({ function QuestionAnswerPanel({
question, question,
answer, answer,
@@ -344,7 +352,7 @@ function QuestionAnswerPanel({
const t = useTranslations("studentDomain.exams.take"); const t = useTranslations("studentDomain.exams.take");
return ( return (
<WorkbenchPanel <WorkbenchPanel
title={`${question.questionNo}.`} title={`${question.questionNo}. ${t("questionUnit")}`}
actions={ actions={
<Button <Button
type="button" type="button"
@@ -361,7 +369,8 @@ function QuestionAnswerPanel({
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium">{question.question}</p> <p className="text-sm font-medium">{question.question}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{question.type} · {question.score} {t("questionType")}: {question.type} · {t("score")}:{" "}
{question.score}
</p> </p>
</div> </div>
@@ -378,9 +387,10 @@ function QuestionAnswerPanel({
size="sm" size="sm"
onClick={onPrev} onClick={onPrev}
disabled={!hasPrev} disabled={!hasPrev}
aria-label="previous" aria-label={t("prevQuestion")}
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
{t("prevQuestion")}
</Button> </Button>
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p> <p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p>
<Button <Button
@@ -389,8 +399,9 @@ function QuestionAnswerPanel({
size="sm" size="sm"
onClick={onNext} onClick={onNext}
disabled={!hasNext} disabled={!hasNext}
aria-label="next" aria-label={t("nextQuestion")}
> >
{t("nextQuestion")}
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
</Button> </Button>
</div> </div>
@@ -399,14 +410,6 @@ function QuestionAnswerPanel({
); );
} }
/**
* 按题型渲染对应答题输入。
*
* - single_choice单选radio
* - multiple_choice多选checkbox逗号分隔
* - fill_blank填空text input
* - essay简答textarea
*/
function AnswerInput({ function AnswerInput({
question, question,
answer, answer,
@@ -445,7 +448,6 @@ function AnswerInput({
/> />
); );
} }
if (type === "essay") {
return ( return (
<textarea <textarea
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
@@ -453,19 +455,8 @@ function AnswerInput({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
); );
}
return (
<Input
type="text"
value={answer}
onChange={(e) => onChange(e.target.value)}
/>
);
} }
/**
* 单选输入:从 options 中选一项answer 为选项索引(字符串)。
*/
function SingleChoiceInput({ function SingleChoiceInput({
options, options,
answer, answer,
@@ -499,9 +490,6 @@ function SingleChoiceInput({
); );
} }
/**
* 多选输入可勾选多项answer 为逗号分隔的索引字符串(升序去重)。
*/
function MultipleChoiceInput({ function MultipleChoiceInput({
options, options,
answer, answer,
@@ -554,13 +542,11 @@ function MultipleChoiceInput({
); );
} }
/**
* 右栏:倒计时 + 作答统计 + 提交按钮。
*/
function TimerSubmitPanel({ function TimerSubmitPanel({
secondsLeft, secondsLeft,
stats, stats,
submitting, submitting,
autoSaveStatus,
onSubmit, onSubmit,
}: { }: {
secondsLeft: number; secondsLeft: number;
@@ -571,25 +557,62 @@ function TimerSubmitPanel({
marked: number; marked: number;
}; };
submitting: boolean; submitting: boolean;
autoSaveStatus: "idle" | "saving" | "saved";
onSubmit: () => void; onSubmit: () => void;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.exams.take"); const t = useTranslations("studentDomain.exams.take");
const timeStr = formatTime(secondsLeft); const timeStr = formatTime(secondsLeft);
const urgent = secondsLeft > 0 && secondsLeft <= 60; const urgent = secondsLeft > 0 && secondsLeft <= 60;
const expired = secondsLeft <= 0;
return ( return (
<WorkbenchPanel title={t("timeRemaining")}> <WorkbenchPanel title={t("timeRemaining")}>
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border bg-background p-4 text-center"> <div
className={`rounded-md border p-4 text-center ${
expired
? "border-destructive bg-destructive/10"
: urgent
? "border-destructive bg-destructive/5 animate-pulse"
: "bg-background"
}`}
role="timer"
aria-live="polite"
aria-label={t("timeRemaining")}
>
<Timer
className={`mx-auto mb-1 size-5 ${
urgent || expired ? "text-destructive" : "text-muted-foreground"
}`}
/>
<p <p
className={`font-mono text-2xl font-semibold ${ className={`font-mono text-2xl font-semibold ${
urgent ? "text-destructive" : "text-foreground" urgent || expired ? "text-destructive" : "text-foreground"
}`} }`}
aria-live="polite"
> >
{timeStr} {timeStr}
</p> </p>
</div> </div>
<div className="rounded-md border bg-background p-3">
<p className="flex items-center gap-1.5 text-xs">
{autoSaveStatus === "saving" ? (
<>
<span className="size-2 animate-pulse rounded-full bg-amber-500" />
<span className="text-muted-foreground">
{t("autoSaveSaving")}
</span>
</>
) : autoSaveStatus === "saved" ? (
<>
<CheckCircle2 className="size-3 text-emerald-600" />
<span className="text-emerald-600">{t("autoSaveTip")}</span>
</>
) : (
<span className="text-muted-foreground">{t("autoSaveIdle")}</span>
)}
</p>
</div>
<div className="space-y-2 rounded-md border bg-background p-4 text-sm"> <div className="space-y-2 rounded-md border bg-background p-4 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">{t("answered")}</span> <span className="text-muted-foreground">{t("answered")}</span>
@@ -616,15 +639,57 @@ function TimerSubmitPanel({
{t("submit")} {t("submit")}
</Button> </Button>
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p> <p className="text-xs text-muted-foreground">{t("makeSureAnswered")}</p>
</div> </div>
</WorkbenchPanel> </WorkbenchPanel>
); );
} }
/** function ConfirmDialog({
* 将秒数格式化为 HH:MM:SS。 unansweredCount,
*/ isBusy,
onCancel,
onConfirm,
}: {
unansweredCount: number;
isBusy: boolean;
onCancel: () => void;
onConfirm: () => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.exams.take");
const tCommon = useTranslations("common");
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
aria-label={t("confirmSubmit")}
>
<div className="mx-4 w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg">
<h3 className="text-base font-semibold">{t("confirmSubmit")}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{unansweredCount > 0
? t("unansweredWarning", { count: unansweredCount })
: t("confirmSubmitDescription")}
</p>
<div className="mt-6 flex justify-end gap-2">
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={isBusy}
>
{tCommon("button.cancel")}
</Button>
<Button type="button" disabled={isBusy} onClick={onConfirm}>
{isBusy ? t("submitting") : t("confirmSubmitAction")}
</Button>
</div>
</div>
</div>
);
}
function formatTime(totalSeconds: number): string { function formatTime(totalSeconds: number): string {
const safe = Math.max(0, Math.floor(totalSeconds)); const safe = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(safe / 3600); const h = Math.floor(safe / 3600);

View File

@@ -1,15 +1,18 @@
// @contract-pendingstudentExams 查询契约待补齐,全 MSW 兜底
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md
"use client"; "use client";
/** /**
* 学生考试列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2 * 学生考试列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
* *
* 数据契约: * 数据契约:
* - studentExams(status) schema 无此字段 → MSW 兜底(@contract-pending * - studentExams(status) schema 无此字段 → MSW 兜底(@contract-pending
* *
* URL 状态:?status=xxx * 视图:
* 状态过滤all / upcoming / inProgress / ended / scored * - 按状态分组(进行中 / 即将开始 / 已结束 / 已出分)
* - 学科筛选pill 按钮组)
* - 每张卡片:标题 + 学科 + 考试时间 + 时长 + 考场 + 座位号 + 状态徽章
* - 即将开始倒计时提示24h 内高亮)
* - 进行中:进入考试(跳 take
* - 已出分/已结束:查看结果(跳 result
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
@@ -17,8 +20,7 @@
*/ */
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useMemo } from "react";
import { useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useStudentExams, type StudentExam } from "@/lib/api"; import { useStudentExams, type StudentExam } from "@/lib/api";
@@ -27,49 +29,74 @@ import {
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
const STATUS_OPTIONS = [ /** 状态分组配置(顺序即展示顺序)。 */
"all", const STATUS_GROUPS = ["inProgress", "upcoming", "ended", "scored"] as const;
"upcoming", type StatusGroup = (typeof STATUS_GROUPS)[number];
"inProgress",
"ended", /** 将后端 status 归一到分组 key。 */
"scored", function toGroup(status: string): StatusGroup {
] as const; const s = status.toLowerCase();
type StatusFilter = (typeof STATUS_OPTIONS)[number]; if (s.includes("progress") || s === "in_progress") return "inProgress";
if (s.includes("upcoming") || s.includes("not_started")) return "upcoming";
if (s.includes("scored") || s.includes("graded")) return "scored";
return "ended";
}
/** 倒计时文案(基于考试日期与当前时间差)。 */
type Translator = ReturnType<typeof useTranslations>;
function getCountdownLabel(
examDate: string,
t: Translator,
): { label: string; urgent: boolean } {
const target = new Date(examDate).getTime();
if (Number.isNaN(target)) return { label: "", urgent: false };
const diff = target - Date.now();
if (diff <= 0) return { label: t("countdownStarted"), urgent: false };
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
const hours = Math.floor(diff / (60 * 60 * 1000));
if (days >= 1)
return { label: t("countdownStartsIn", { n: days }), urgent: false };
if (hours >= 1)
return { label: t("countdownHoursLeft", { n: hours }), urgent: true };
return { label: t("countdownUrgent"), urgent: true };
}
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/ */
export function StudentExamsListClient(): React.ReactElement { export function StudentExamsListClient(): React.ReactElement {
const t = useTranslations("studentDomain.exams.list"); const t = useTranslations("studentDomain.exams.list");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const statusParam = searchParams.get("status") ?? "all"; // @contract-pendingMSW 兜底,不传 status 取全量后客户端分组
const statusFilter: StatusFilter = ( const { data, loading, error } = useStudentExams({});
STATUS_OPTIONS as readonly string[]
).includes(statusParam)
? (statusParam as StatusFilter)
: "all";
// @contract-pendingMSW 兜底 const exams = data ?? [];
const { data, loading, error } = useStudentExams(
statusFilter === "all" ? {} : { status: statusFilter },
);
const updateQuery = (key: string, value: string): void => { // 学科选项(去重,保留出现顺序)
const params = new URLSearchParams(searchParams.toString()); const subjectOptions = useMemo<string[]>(() => {
if (value && value !== "all") { const seen = new Set<string>();
params.set(key, value); const list: string[] = [];
} else { for (const e of exams) {
params.delete(key); if (!seen.has(e.subject)) {
seen.add(e.subject);
list.push(e.subject);
} }
startTransition(() => { }
router.push(`/shell/student/exams?${params.toString()}`); return list;
}); }, [exams]);
};
// 按状态分组
const grouped = useMemo(() => {
const map = new Map<StatusGroup, StudentExam[]>();
for (const g of STATUS_GROUPS) map.set(g, []);
for (const e of exams) {
const g = toGroup(e.status);
map.get(g)?.push(e);
}
return map;
}, [exams]);
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -86,139 +113,247 @@ export function StudentExamsListClient(): React.ReactElement {
description={t("description")} description={t("description")}
icon={<FileText className="size-6" />} icon={<FileText className="size-6" />}
filters={ filters={
<select <SubjectFilterPills
value={statusFilter} options={subjectOptions}
onChange={(e) => updateQuery("status", e.target.value)} disabled={exams.length === 0}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" />
aria-label={t("statusFilter")}
>
<option value="all">{t("allStatus")}</option>
<option value="upcoming">{t("statusUpcoming")}</option>
<option value="inProgress">{t("statusInProgress")}</option>
<option value="ended">{t("statusEnded")}</option>
<option value="scored">{t("statusScored")}</option>
</select>
} }
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={4} />}
empty={(data?.length ?? 0) === 0 && !loading} empty={!loading && !error && exams.length === 0}
emptyNode={ emptyNode={<ExamsEmptyState />}
<div className="rounded-xl border p-10 text-center text-muted-foreground">
{t("emptyTitle")}
</div>
}
errorNode={errorNode} errorNode={errorNode}
pagination={ pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: data?.length ?? 0 })}</span> <span>{t("total", { count: exams.length })}</span>
</div> </div>
} }
> >
<ExamsTable items={data ?? []} /> <div className="space-y-8">
{STATUS_GROUPS.map((g) => {
const items = grouped.get(g) ?? [];
if (items.length === 0) return null;
return <ExamStatusGroup key={g} group={g} items={items} />;
})}
</div>
</ListPageShell> </ListPageShell>
); );
} }
/** /** 学科筛选 pill 按钮组(客户端状态由 URL 管理,此处仅展示按钮组容器)。 */
* 学生考试列表表格(纯展示组件,对齐 §8.2 排版规范)。 function SubjectFilterPills({
*/ options,
function ExamsTable({ items }: { items: StudentExam[] }): React.ReactElement { disabled,
}: {
options: string[];
disabled: boolean;
}): React.ReactElement {
const t = useTranslations("studentDomain.exams.list"); const t = useTranslations("studentDomain.exams.list");
if (disabled) return <span className="text-xs text-muted-foreground" />;
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div
<table className="w-full text-sm"> className="flex flex-wrap items-center gap-2"
<thead className="border-b bg-muted/30"> role="group"
<tr> aria-label={t("subjectFilter")}
<th className="p-3 text-left font-medium">{t("colTitle")}</th> >
<th className="p-3 text-left font-medium">{t("colSubject")}</th> <span className="text-xs text-muted-foreground">
<th className="p-3 text-left font-medium">{t("colExamDate")}</th> {t("subjectFilter")}
<th className="p-3 text-left font-medium">{t("colDuration")}</th> </span>
<th className="p-3 text-left font-medium">{t("colLocation")}</th> {options.map((subj) => (
<th className="p-3 text-left font-medium">{t("colSeatNo")}</th> <span
<th className="p-3 text-left font-medium">{t("colStatus")}</th> key={subj}
<th className="p-3 text-left font-medium">{t("colScore")}</th> className="inline-flex h-7 items-center rounded-full border bg-background px-3 text-xs text-muted-foreground"
<th className="p-3 text-right font-medium">{t("colActions")}</th> >
</tr> {subj}
</thead> </span>
<tbody className="divide-y">
{items.map((exam) => (
<tr key={exam.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{exam.title}</td>
<td className="p-3">{exam.subject}</td>
<td className="p-3 font-mono text-xs">{exam.examDate}</td>
<td className="p-3 text-xs">{exam.duration}</td>
<td className="p-3">{exam.location}</td>
<td className="p-3">{exam.seatNo}</td>
<td className="p-3">
<ExamStatusBadge status={exam.status} />
</td>
<td className="p-3">{exam.score === null ? "--" : exam.score}</td>
<td className="p-3 text-right">{renderAction(exam)}</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
); );
} }
/** /** 按状态分组的考试卡片组。 */
* 按考试状态渲染对应操作链接: function ExamStatusGroup({
* - upcoming → 进入考试(跳 take group,
* - 其他(进行中/已结束/已出分)→ 查看结果(跳 result items,
*/ }: {
function renderAction(exam: StudentExam): React.ReactElement { group: StatusGroup;
items: StudentExam[];
}): React.ReactElement {
const t = useTranslations("studentDomain.exams.list"); const t = useTranslations("studentDomain.exams.list");
if (exam.status === "upcoming") { const labelKey = `group${group.charAt(0).toUpperCase()}${group.slice(1)}`;
const descKey = `${labelKey}Desc`;
const label = t(labelKey as Parameters<typeof t>[0]);
const desc = t(descKey as Parameters<typeof t>[0]);
return ( return (
<Link <section aria-label={label}>
href={`/shell/student/exams/${exam.id}/take`} <div className="mb-3 flex items-baseline gap-3">
className="text-xs text-primary hover:underline" <h2 className="text-lg font-semibold">{label}</h2>
> <span className="text-xs text-muted-foreground">
{t("takeExam")} {items.length}{" "}
</Link> {t("total", { count: items.length })
.replace(/^[^\d]*/, "")
.trim()
? ""
: ""}
{" · "}
{desc}
</span>
</div>
<ul className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{items.map((exam) => (
<li key={exam.id}>
<ExamCard exam={exam} />
</li>
))}
</ul>
</section>
); );
} }
/** 单个考试卡片。 */
function ExamCard({ exam }: { exam: StudentExam }): React.ReactElement {
const t = useTranslations("studentDomain.exams.list");
const group = toGroup(exam.status);
const isTakeable = group === "inProgress";
const isResult = group === "scored" || group === "ended";
const linkHref = isTakeable
? `/shell/student/exams/${exam.id}/take`
: isResult
? `/shell/student/exams/${exam.id}/result`
: null;
const countdown =
isTakeable || group === "upcoming"
? getCountdownLabel(exam.examDate, t)
: null;
const badgeCls = examStatusBadgeClass(group);
const inner = (
<article
className={`flex h-full flex-col rounded-xl border bg-card p-5 transition-shadow ${
linkHref ? "hover:shadow-md" : ""
}`}
aria-label={`${exam.title} - ${exam.status}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{exam.subject}
</p>
<h3 className="mt-1 truncate text-lg font-semibold">{exam.title}</h3>
</div>
<span
className={`inline-flex h-6 shrink-0 items-center rounded-full px-2 text-xs font-medium ${badgeCls}`}
>
{examStatusLabel(group, t)}
</span>
</div>
<div className="my-4 border-t" />
<dl className="grid grid-cols-2 gap-y-2 text-xs">
<div>
<dt className="uppercase tracking-wider text-muted-foreground">
{t("colExamDate")}
</dt>
<dd className="mt-1 font-mono">{exam.examDate}</dd>
</div>
<div>
<dt className="uppercase tracking-wider text-muted-foreground">
{t("colDuration")}
</dt>
<dd className="mt-1">{exam.duration} min</dd>
</div>
<div>
<dt className="uppercase tracking-wider text-muted-foreground">
{t("colLocation")}
</dt>
<dd className="mt-1">{exam.location}</dd>
</div>
<div>
<dt className="uppercase tracking-wider text-muted-foreground">
{t("colSeatNo")}
</dt>
<dd className="mt-1">{exam.seatNo}</dd>
</div>
</dl>
{countdown && countdown.label ? (
<p
className={`mt-3 text-xs ${countdown.urgent ? "text-destructive" : "text-primary"}`}
aria-live="polite"
>
{countdown.label}
</p>
) : null}
{exam.score !== null && group === "scored" ? (
<p className="mt-3 text-sm">
<span className="text-xs text-muted-foreground">
{t("colScore")}
</span>
<span className="font-mono text-base font-semibold text-primary">
{exam.score}
</span>
</p>
) : null}
{linkHref ? (
<p className="mt-3 text-xs text-primary">
{isTakeable ? t("takeExam") : t("viewResult")}
</p>
) : null}
</article>
);
if (!linkHref) return inner;
return ( return (
<Link <Link
href={`/shell/student/exams/${exam.id}/result`} href={linkHref}
className="text-xs text-muted-foreground hover:text-foreground" className="block h-full focus-visible:outline-2"
aria-label={`${exam.title} - ${isTakeable ? t("takeExam") : t("viewResult")}`}
> >
{t("viewResult")} {inner}
</Link> </Link>
); );
} }
/** /** 考试状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。 */
* 学生考试状态徽章(按状态色阶展示)。 function examStatusBadgeClass(group: StatusGroup): string {
*/ switch (group) {
function ExamStatusBadge({ status }: { status: string }): React.ReactElement { case "inProgress":
return "bg-destructive/10 text-destructive";
case "upcoming":
return "bg-primary/10 text-primary";
case "scored":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
default:
return "bg-muted text-muted-foreground";
}
}
/** 考试状态 → 本地化标签。 */
function examStatusLabel(group: StatusGroup, t: (k: string) => string): string {
switch (group) {
case "inProgress":
return t("statusInProgress");
case "upcoming":
return t("statusUpcoming");
case "scored":
return t("statusScored");
default:
return t("statusEnded");
}
}
/** 空状态。 */
function ExamsEmptyState(): React.ReactElement {
const t = useTranslations("studentDomain.exams.list"); const t = useTranslations("studentDomain.exams.list");
const label =
status === "upcoming"
? t("statusUpcoming")
: status === "inProgress"
? t("statusInProgress")
: status === "ended"
? t("statusEnded")
: status === "scored"
? t("statusScored")
: status;
const cls =
status === "upcoming"
? "bg-primary/10 text-primary"
: status === "inProgress"
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
: status === "ended"
? "bg-muted text-muted-foreground"
: status === "scored"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground";
return ( return (
<span <div className="flex min-h-80 flex-col items-center justify-center gap-2 rounded-xl border p-8 text-center">
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`} <p className="text-lg font-semibold">{t("emptyTitle")}</p>
> <p className="text-sm text-muted-foreground">{t("emptyDesc")}</p>
{label} </div>
</span>
); );
} }

View File

@@ -7,13 +7,22 @@
* - studentGrades(subject, type, q):❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentGrades(subject, type, q):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-grades-list * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-grades-list
* *
* URL 状态:?subject=xxx &type=xxx &q=xxx * 视图结构(复刻 CICD my-grades/page.tsx 数据维度并扩展):
* 1. 4 维筛选subject / type / semester / keyword
* 2. 成绩汇总卡(记录数 / 平均得分率 / 及格率 / 优秀率)
* 3. 成绩趋势卡SVG 折线图,学生得分率随时间变化;班级平均线 @contract-pending
* 4. 排名趋势卡SVG 折线图Y 轴反向,展示班级排名变化)
* 5. 班级分布图SVG 柱状图,高亮当前学生所在分数段)
* 6. 成长档案SVG 多折线图,跨学期各学科成绩变化)
* 7. 成绩明细表(学科/类型/得分/满分/得分率/等级/日期/操作)
*
* URL 状态:?subject=xxx &type=xxx &semester=xxx &q=xxx
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { Award } from "lucide-react"; import { Award, TrendingUp, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
@@ -21,28 +30,51 @@ import { useTranslations } from "next-intl";
import { useStudentGrades, type StudentGrade } from "@/lib/api"; import { useStudentGrades, type StudentGrade } from "@/lib/api";
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 { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import {
FilterBar,
FilterSearchInput,
} from "@/shared/components/ui/filter-bar";
import { StatCard } from "@/shared/components/ui/stat-card";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { RankingTrendCard } from "./components/ranking-trend-card";
import { ScoreDistributionCard } from "./components/score-distribution-card";
import { GrowthArchiveCard } from "./components/growth-archive-card";
/** 学科枚举(@contract-pending待后端契约补齐后改为动态拉取 */ /** 学科枚举@contract-pending待后端契约补齐后改为动态拉取 */
const SUBJECT_OPTIONS = [ const SUBJECT_OPTION_KEYS = [
"语文", "chinese",
"数学", "math",
"英语", "english",
"物理", "physics",
"化学", "chemistry",
"生物", "biology",
"历史", "history",
"地理", "geography",
"政治", "politics",
] as const; ] as const;
/** 成绩类型枚举(@contract-pending待后端契约补齐后改为动态拉取 */ /** 成绩类型枚举@contract-pending待后端契约补齐后改为动态拉取 */
const TYPE_OPTIONS = ["考试", "作业", "测验", "综合"] as const; const TYPE_OPTION_KEYS = ["exam", "homework", "quiz", "comprehensive"] as const;
/** 学期枚举(@contract-pending待后端契约补齐后改为动态拉取 */
const SEMESTER_OPTION_KEYS = ["1", "2"] as const;
/** 及格线得分率阈值scoreRate >= 0.6 视为及格) */
const PASS_RATE_THRESHOLD = 0.6;
/** 优秀线得分率阈值scoreRate >= 0.85 视为优秀) */
const EXCELLENT_RATE_THRESHOLD = 0.85;
/** /**
* 成绩列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 成绩列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -57,6 +89,7 @@ export function StudentGradesListClient(): React.ReactElement {
const subject = searchParams.get("subject") ?? ""; const subject = searchParams.get("subject") ?? "";
const type = searchParams.get("type") ?? ""; const type = searchParams.get("type") ?? "";
const semester = searchParams.get("semester") ?? "";
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
@@ -66,9 +99,26 @@ export function StudentGradesListClient(): React.ReactElement {
q: q || undefined, q: q || undefined,
}); });
// 数据契约data 为 StudentGradesResultitems + rankingTrend + distribution + growthArchive
const items = data?.items;
const rankingTrend = data?.rankingTrend;
const distribution = data?.distribution;
const growthArchive = data?.growthArchive;
// 学期筛选在客户端完成hook 暂未支持 semester 参数)
const filteredItems = useMemo<StudentGrade[]>(() => { const filteredItems = useMemo<StudentGrade[]>(() => {
return data ?? []; if (!items) return [];
}, [data]); if (!semester) return items;
return items.filter((g) => deriveSemester(g.date) === semester);
}, [items, semester]);
const summary = useMemo(() => computeSummary(filteredItems), [filteredItems]);
const trendPoints = useMemo(
() => buildTrendPoints(filteredItems),
[filteredItems],
);
const hasFilters = Boolean(subject || type || semester || q);
const updateQuery = (key: string, value: string): void => { const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -82,6 +132,12 @@ export function StudentGradesListClient(): React.ReactElement {
}); });
}; };
const resetFilters = (): void => {
startTransition(() => {
router.push("/shell/student/grades");
});
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -91,7 +147,13 @@ export function StudentGradesListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
const emptyNode = <EmptyState icon={Award} title={t("emptyTitle")} />; const emptyNode = (
<EmptyState
icon={Award}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
);
return ( return (
<ListPageShell <ListPageShell
@@ -99,7 +161,7 @@ export function StudentGradesListClient(): React.ReactElement {
description={t("description")} description={t("description")}
icon={<Award className="size-6" />} icon={<Award className="size-6" />}
filters={ filters={
<> <FilterBar variant="wrap">
<FilterSearchInput <FilterSearchInput
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
value={q} value={q}
@@ -112,9 +174,9 @@ export function StudentGradesListClient(): React.ReactElement {
aria-label={t("subjectFilter")} aria-label={t("subjectFilter")}
> >
<option value="">{t("allSubjects")}</option> <option value="">{t("allSubjects")}</option>
{SUBJECT_OPTIONS.map((s) => ( {SUBJECT_OPTION_KEYS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{s} {t(`subjects.${s}`)}
</option> </option>
))} ))}
</select> </select>
@@ -125,13 +187,37 @@ export function StudentGradesListClient(): React.ReactElement {
aria-label={t("typeFilter")} aria-label={t("typeFilter")}
> >
<option value="">{t("allTypes")}</option> <option value="">{t("allTypes")}</option>
{TYPE_OPTIONS.map((tp) => ( {TYPE_OPTION_KEYS.map((tp) => (
<option key={tp} value={tp}> <option key={tp} value={tp}>
{tp} {t(`types.${tp}`)}
</option> </option>
))} ))}
</select> </select>
</> <select
value={semester}
onChange={(e) => updateQuery("semester", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("semesterFilter")}
>
<option value="">{t("allSemesters")}</option>
{SEMESTER_OPTION_KEYS.map((sm) => (
<option key={sm} value={sm}>
{sm === "1" ? t("semester1") : t("semester2")}
</option>
))}
</select>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={resetFilters}
className="h-9"
>
<X className="size-4" />
{t("resetFilters")}
</Button>
) : null}
</FilterBar>
} }
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
@@ -144,11 +230,170 @@ export function StudentGradesListClient(): React.ReactElement {
</div> </div>
} }
> >
<div className="flex flex-col gap-6">
{/* 成绩汇总卡 */}
<section aria-label={t("sectionSummary")}>
<StatsGrid columns={4}>
<StatCard
title={t("fieldTotalRecords")}
value={String(summary.total)}
/>
<StatCard
title={t("fieldAverageScore")}
value={formatRate(summary.averageScoreRate)}
/>
<StatCard
title={t("fieldPassRate")}
value={formatRate(summary.passRate)}
/>
<StatCard
title={t("fieldExcellentRate")}
value={formatRate(summary.excellentRate)}
/>
</StatsGrid>
</section>
{/* 趋势 / 排名 / 分布 / 成长档案 四宫格 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<TrendCard points={trendPoints} />
<RankingTrendCard points={rankingTrend} />
<ScoreDistributionCard distribution={distribution} />
<GrowthArchiveCard points={growthArchive} />
</div>
{/* 成绩明细表 */}
<StudentGradesTable items={filteredItems} /> <StudentGradesTable items={filteredItems} />
</div>
</ListPageShell> </ListPageShell>
); );
} }
/** 趋势图数据点。 */
interface TrendPoint {
date: string;
scoreRate: number;
}
/**
* 成绩趋势卡(纯 SVG 折线图,展示得分率随时间变化)。
* 班级平均线 @contract-pending目前仅绘制学生本人趋势。
*/
function TrendCard({ points }: { points: TrendPoint[] }): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
if (points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="size-4 text-muted-foreground" />
{t("sectionTrend")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[160px] flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-muted-foreground">
{t("trendEmptyTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("trendEmptyDescription")}
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="size-4 text-muted-foreground" />
{t("sectionTrend")}
</CardTitle>
</CardHeader>
<CardContent>
<TrendChart points={points} />
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<span className="inline-block size-2 rounded-full bg-primary" />
{t("trendScorePercent")}
</span>
<span className="inline-flex items-center gap-1 opacity-60">
<span className="inline-block size-2 rounded-full bg-muted-foreground" />
{t("trendClassAverage")}
</span>
</div>
</CardContent>
</Card>
);
}
/** 简易 SVG 折线图(无外部图表库依赖,对齐 §3.10 设计令牌规范)。 */
function TrendChart({ points }: { points: TrendPoint[] }): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
const width = 480;
const height = 160;
const padX = 32;
const padY = 16;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const n = points.length;
const xStep = n > 1 ? innerW / (n - 1) : 0;
const toX = (i: number): number => padX + i * xStep;
const toY = (rate: number): number => padY + innerH - rate * innerH;
const pathD =
n === 1
? `M ${toX(0)} ${toY(points[0]!.scoreRate)}`
: points
.map((p, i) => `${i === 0 ? "M" : "L"} ${toX(i)} ${toY(p.scoreRate)}`)
.join(" ");
return (
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-40 w-full"
role="img"
aria-label={t("sectionTrend")}
>
{/* 网格线 (0% / 50% / 100%) */}
{[0, 0.5, 1].map((g) => (
<line
key={g}
x1={padX}
x2={width - padX}
y1={toY(g)}
y2={toY(g)}
className="stroke-muted"
strokeWidth={1}
strokeDasharray="2 4"
/>
))}
{/* 学生趋势折线 */}
<path
d={pathD}
fill="none"
className="stroke-primary"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* 数据点 */}
{points.map((p, i) => (
<circle
key={`${p.date}-${i}`}
cx={toX(i)}
cy={toY(p.scoreRate)}
r={3}
className="fill-primary"
/>
))}
</svg>
</div>
);
}
/** /**
* 学生成绩表格(纯展示组件,对齐 §8.2 排版规范)。 * 学生成绩表格(纯展示组件,对齐 §8.2 排版规范)。
*/ */
@@ -214,6 +459,53 @@ function StudentGradesTable({
); );
} }
/** 成绩汇总统计。 */
interface GradeSummary {
total: number;
averageScoreRate: number;
passRate: number;
excellentRate: number;
}
/** 从成绩列表计算汇总统计。 */
function computeSummary(items: StudentGrade[]): GradeSummary {
const total = items.length;
if (total === 0) {
return { total: 0, averageScoreRate: 0, passRate: 0, excellentRate: 0 };
}
const sumRate = items.reduce((sum, g) => sum + g.scoreRate, 0);
const passCount = items.filter(
(g) => g.scoreRate >= PASS_RATE_THRESHOLD,
).length;
const excellentCount = items.filter(
(g) => g.scoreRate >= EXCELLENT_RATE_THRESHOLD,
).length;
return {
total,
averageScoreRate: sumRate / total,
passRate: passCount / total,
excellentRate: excellentCount / total,
};
}
/** 构建趋势图数据点(按日期升序)。 */
function buildTrendPoints(items: StudentGrade[]): TrendPoint[] {
return [...items]
.filter((g) => g.date && Number.isFinite(g.scoreRate))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((g) => ({ date: g.date, scoreRate: g.scoreRate }));
}
/** 根据 ISO 日期推导学期9月-次年1月 = 第一学期2-7月 = 第二学期)。 */
function deriveSemester(isoDate: string): string {
if (!isoDate) return "";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "";
const month = d.getMonth() + 1;
if (month >= 9 || month <= 1) return "1";
return "2";
}
/** 格式化分数为展示字符串,保留 1 位小数。 */ /** 格式化分数为展示字符串,保留 1 位小数。 */
function formatScore(score: number): string { function formatScore(score: number): string {
if (!Number.isFinite(score)) return "--"; if (!Number.isFinite(score)) return "--";

View File

@@ -4,37 +4,66 @@
* 学生成绩报告卡页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2 * 学生成绩报告卡页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
* *
* 数据契约: * 数据契约:
* - studentReportCard(academicYearId, semester)❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentReportCard(academicYearId, semester) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-report-card * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-report-card
* *
* 视图结构:
* 1. 学年/学期切换器URL 状态)
* 2. 面包屑导航(返回成绩列表)
* 3. A4 报告卡容器(.report-card-a4打印时仅此区域可见
* - 学校抬头 / 学期信息
* - 学生基本信息区(姓名 / 班级 / 班主任 / 生成时间)
* - 各科成绩明细表rowSpan 合并同学科单元格 + 学科排名 / 平均分)
* - 综合统计(总平均分 / 总排名 / 及格率 / 优秀率)
* - 教师评语
* - 签名区
* 4. 打印动作window.print含 loading 状态与错误兜底)
*
* URL 状态:?academicYearId=xxx &semester=xxx * URL 状态:?academicYearId=xxx &semester=xxx
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级 + EmptyState/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { FileText } from "lucide-react"; import { FileText, Printer } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react"; import { useCallback, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type {
StudentReportCard,
StudentReportCardRecord,
StudentReportCardSubject,
} from "@/lib/api";
import { useStudentReportCard } from "@/lib/api"; import { useStudentReportCard } from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { import {
DetailPageShell, DetailPageShell,
DetailPageSkeleton, DetailPageSkeleton,
DetailSection, DetailSection,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/** 学年枚举@contract-pending待后端契约补齐后改为动态拉取 */ /** 学年选项@contract-pending待后端契约补齐后改为动态拉取 */
const ACADEMIC_YEAR_OPTIONS = ["2024-2025", "2025-2026"] as const; const ACADEMIC_YEAR_OPTIONS = ["2024-2025", "2025-2026"] as const;
/** 学期枚举@contract-pending待后端契约补齐后改为动态拉取 */ /** 学期选项@contract-pending待后端契约补齐后改为动态拉取 */
const SEMESTER_OPTIONS = ["1", "2"] as const; const SEMESTER_OPTIONS = ["1", "2"] as const;
/** 及格线得分率阈值scoreRate >= 0.6 视为及格) */
const PASS_RATE_THRESHOLD = 0.6;
/** 优秀线得分率阈值scoreRate >= 0.85 视为优秀) */
const EXCELLENT_RATE_THRESHOLD = 0.85;
/** 打印前 loading 延迟ms让 UI 先渲染"准备中…" */
const PRINT_LOADING_DELAY_MS = 50;
/** /**
* 报告卡客户端主体。需 server page 包裹在 <Suspense> 中 * 成绩报告卡客户端主体。需 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。 * useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/ */
export function StudentReportCardClient(): React.ReactElement { export function StudentReportCardClient(): React.ReactElement {
@@ -43,6 +72,7 @@ export function StudentReportCardClient(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [isPrinting, setIsPrinting] = useState(false);
const academicYearId = searchParams.get("academicYearId") ?? ""; const academicYearId = searchParams.get("academicYearId") ?? "";
const semester = searchParams.get("semester") ?? ""; const semester = searchParams.get("semester") ?? "";
@@ -53,6 +83,8 @@ export function StudentReportCardClient(): React.ReactElement {
semester: semester || undefined, semester: semester || undefined,
}); });
const summary = useMemo(() => computeSummary(data?.subjects ?? []), [data]);
const updateQuery = (key: string, value: string): void => { const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
if (value) { if (value) {
@@ -65,24 +97,84 @@ export function StudentReportCardClient(): React.ReactElement {
}); });
}; };
const handlePrint = (): void => { const handlePrint = useCallback((): void => {
if (typeof window !== "undefined") { if (typeof window === "undefined") return;
try {
setIsPrinting(true);
const handleAfterPrint = (): void => {
setIsPrinting(false);
window.removeEventListener("afterprint", handleAfterPrint);
};
window.addEventListener("afterprint", handleAfterPrint);
window.setTimeout(() => {
try {
window.print(); window.print();
} catch (err) {
notify.error(t("errorPrint"));
console.error("Print failed:", err);
setIsPrinting(false);
window.removeEventListener("afterprint", handleAfterPrint);
} }
}, PRINT_LOADING_DELAY_MS);
} catch (err) {
notify.error(t("errorPrint"));
console.error("Print setup failed:", err);
setIsPrinting(false);
}
}, [t]);
const handleReset = (): void => {
startTransition(() => {
router.push("/shell/student/grades/report-card");
});
}; };
const errorNode = error ? ( if (loading) {
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> return <DetailPageSkeleton />;
<p className="text-sm text-destructive"> }
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
const emptyNode = if (error) {
!loading && !error && !data ? ( return (
<EmptyState icon={FileText} title={t("emptyTitle")} /> <DetailPageShell
) : undefined; title={t("title")}
description={t("description")}
icon={<FileText className="size-6" />}
backHref="/shell/student/grades"
>
<EmptyState
icon={FileText}
title={tCommon("error.loadFailed", { message: String(error) })}
description={t("emptyDescription")}
/>
</DetailPageShell>
);
}
if (!data || data.subjects.length === 0) {
return (
<DetailPageShell
title={t("title")}
description={t("description")}
icon={<FileText className="size-6" />}
backHref="/shell/student/grades"
actions={
<Button variant="outline" onClick={handlePrint}>
<Printer className="mr-2 size-4" />
{t("print")}
</Button>
}
>
<EmptyState
icon={FileText}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
</DetailPageShell>
);
}
const semesterLabel = semester ? t(`semester${semester}`) : t("allSemesters");
return ( return (
<DetailPageShell <DetailPageShell
@@ -91,223 +183,555 @@ export function StudentReportCardClient(): React.ReactElement {
icon={<FileText className="size-6" />} icon={<FileText className="size-6" />}
backHref="/shell/student/grades" backHref="/shell/student/grades"
actions={ actions={
<Button onClick={handlePrint} disabled={!data}> <Button variant="outline" onClick={handlePrint} disabled={isPrinting}>
{t("print")} <Printer className="mr-2 size-4" />
{isPrinting ? t("preparing") : t("print")}
</Button> </Button>
} }
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
> >
{data ? <ReportCardBody data={data} /> : null} {/* 学年/学期切换器(打印时隐藏) */}
<ReportCardFilters <DetailSection title={t("periodSelectorTitle")}>
academicYearId={academicYearId} <div className="flex flex-wrap items-center gap-3">
semester={semester} <label className="text-sm text-muted-foreground">
onAcademicYearChange={(v) => updateQuery("academicYearId", v)} {t("academicYearLabel")}
onSemesterChange={(v) => updateQuery("semester", v)} </label>
<select
value={academicYearId}
onChange={(e) => updateQuery("academicYearId", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("academicYearLabel")}
>
<option value="">{t("allAcademicYears")}</option>
{ACADEMIC_YEAR_OPTIONS.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<label className="text-sm text-muted-foreground">
{t("semesterLabel")}
</label>
<select
value={semester}
onChange={(e) => updateQuery("semester", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("semesterLabel")}
>
<option value="">{t("allSemesters")}</option>
{SEMESTER_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(`semester${s}`)}
</option>
))}
</select>
<Button variant="ghost" size="sm" onClick={handleReset}>
{t("resetFilters")}
</Button>
</div>
</DetailSection>
{/* A4 报告卡主体(打印时仅此区域可见) */}
<ReportCardA4
data={data}
academicYear={data.academicYear || academicYearId}
semester={semesterLabel}
summary={summary}
/> />
{/* 综合统计(屏幕视图,打印时随 A4 容器内汇总区呈现) */}
<DetailSection title={t("summarySectionTitle")}>
<StatsGrid columns={4}>
<SummaryStat
label={t("overallAverage")}
value={summary.averageScore.toFixed(1)}
/>
<SummaryStat
label={t("overallRank")}
value={
data.overallRank != null && data.classTotalStudents != null
? t("rankFormat", {
rank: data.overallRank,
total: data.classTotalStudents,
})
: "--"
}
/>
<SummaryStat
label={t("passRate")}
value={formatPercent(summary.passRate)}
/>
<SummaryStat
label={t("excellentRate")}
value={formatPercent(summary.excellentRate)}
/>
</StatsGrid>
</DetailSection>
</DetailPageShell> </DetailPageShell>
); );
} }
/** /** A4 报告卡容器:学校抬头 + 学生信息 + 学科明细 + 汇总 + 评语 + 签名。 */
* 报告卡筛选器(学年/学期切换)。 function ReportCardA4({
*/
function ReportCardFilters({
academicYearId,
semester,
onAcademicYearChange,
onSemesterChange,
}: {
academicYearId: string;
semester: string;
onAcademicYearChange: (v: string) => void;
onSemesterChange: (v: string) => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.grades.reportCard");
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 sm:flex-row sm:items-center sm:gap-6">
<div className="flex items-center gap-2">
<label
htmlFor="academic-year-select"
className="text-sm text-muted-foreground"
>
{t("academicYearLabel")}
</label>
<select
id="academic-year-select"
value={academicYearId}
onChange={(e) => onAcademicYearChange(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">{t("academicYearLabel")}</option>
{ACADEMIC_YEAR_OPTIONS.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="semester-select"
className="text-sm text-muted-foreground"
>
{t("semesterLabel")}
</label>
<select
id="semester-select"
value={semester}
onChange={(e) => onSemesterChange(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">{t("semesterLabel")}</option>
{SEMESTER_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
);
}
/**
* 报告卡内容区(成绩汇总表)。
*/
function ReportCardBody({
data, data,
}: {
data: NonNullable<ReturnType<typeof useStudentReportCard>["data"]>;
}): React.ReactElement {
const t = useTranslations("studentDomain.grades.reportCard");
return (
<DetailSection title={t("sectionSummary")}>
<ReportCardTable
subjects={data.subjects}
academicYear={data.academicYear}
semester={data.semester}
/>
</DetailSection>
);
}
/**
* 成绩汇总表格。
*/
function ReportCardTable({
subjects,
academicYear, academicYear,
semester, semester,
summary,
}: { }: {
subjects: NonNullable< data: StudentReportCard;
ReturnType<typeof useStudentReportCard>["data"]
>["subjects"];
academicYear: string; academicYear: string;
semester: string; semester: string;
summary: ReportCardSummary;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.grades.reportCard"); const t = useTranslations("studentDomain.grades.reportCard");
if (subjects.length === 0) {
return <p className="text-sm text-muted-foreground">{t("emptyTitle")}</p>;
}
return ( return (
<div className="overflow-x-auto"> <div className="report-card-a4 mx-auto w-full max-w-3xl rounded-lg border-2 bg-white p-8 shadow-sm">
<table className="w-full text-sm"> {/* 学校抬头 */}
<thead> <header className="border-b-2 border-black pb-3 text-center">
<tr className="border-b text-left text-muted-foreground"> <h1 className="text-2xl font-bold">{t("reportCardTitle")}</h1>
<th className="py-2 pr-4 font-medium">{t("colSubject")}</th> <p className="mt-1 text-sm text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("colScore")}</th> {t("periodLabel", { year: academicYear || "--", semester })}
<th className="py-2 pr-4 font-medium">{t("colTotalScore")}</th> </p>
<th className="py-2 pr-4 font-medium">{t("colScoreRate")}</th> </header>
<th className="py-2 pr-4 font-medium">{t("colLevel")}</th>
<th className="py-2 pr-4 font-medium">{t("colTeacherComment")}</th> {/* 学生基本信息 */}
</tr> <section className="report-card-student-info mt-4">
</thead> <h2 className="mb-2 text-base font-semibold">
<tbody> {t("studentInfoTitle")}
{subjects.map((entry, idx) => ( </h2>
<tr <div className="grid grid-cols-2 gap-x-6 gap-y-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-4">
key={`${entry.subject}-${idx}`} <InfoField
className="border-b last:border-0" label={t("studentNameLabel")}
value={data.studentName ?? "--"}
/>
<InfoField
label={t("classNameLabel")}
value={data.className ?? "--"}
/>
<InfoField
label={t("classTeacherLabel")}
value={data.classTeacherName ?? "--"}
/>
<InfoField
label={t("generatedAtLabel")}
value={formatDateTime(data.generatedAt)}
/>
</div>
</section>
{/* 各科成绩明细rowSpan 合并同学科单元格) */}
<section className="report-card-grades mt-4">
<h2 className="mb-2 text-base font-semibold">
{t("gradesSectionTitle")}
</h2>
<GradesTable subjects={data.subjects} />
</section>
{/* 综合统计A4 内汇总区,打印时不被分页切割) */}
<section className="report-card-summary mt-4 rounded-md border bg-muted/20 p-3">
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<SummaryStat
label={t("overallAverage")}
value={summary.averageScore.toFixed(1)}
/>
<SummaryStat
label={t("overallRank")}
value={
data.overallRank != null && data.classTotalStudents != null
? t("rankFormat", {
rank: data.overallRank,
total: data.classTotalStudents,
})
: "--"
}
/>
<SummaryStat
label={t("passRate")}
value={formatPercent(summary.passRate)}
/>
<SummaryStat
label={t("excellentRate")}
value={formatPercent(summary.excellentRate)}
/>
</div>
</section>
{/* 教师评语 */}
<section
className="report-card-comments mt-4"
aria-label={t("commentsAriaLabel")}
> >
<td className="py-2 pr-4 font-medium">{entry.subject}</td> <h2 className="mb-2 text-base font-semibold">{t("commentsTitle")}</h2>
<td className="py-2 pr-4"> <div className="space-y-2">
<span className="font-semibold"> {data.subjects.filter((s) => s.teacherComment).length === 0 ? (
{formatScore(entry.score)} <p className="text-sm text-muted-foreground">
</span> {t("commentsPlaceholder")}
</td> </p>
<td className="py-2 pr-4 text-muted-foreground"> ) : (
{formatScore(entry.totalScore)} data.subjects
</td> .filter((s) => s.teacherComment)
<td className="py-2 pr-4"> .map((s) => (
<span className={scoreRateToColorClass(entry.scoreRate)}> <div
{formatRate(entry.scoreRate)} key={s.subject}
</span> className="rounded-md border bg-muted/30 p-2 text-sm"
</td> >
<td className="py-2 pr-4"> <p className="font-medium">{s.subject}</p>
<span className={levelToColorClass(entry.level)}> <p className="mt-1 text-muted-foreground">
{entry.level} {s.teacherComment}
</span> </p>
</td> </div>
<td className="py-2 pr-4 text-xs text-muted-foreground"> ))
{entry.teacherComment || "--"} )}
</td> </div>
</tr> </section>
))}
</tbody> {/* 签名区 */}
{academicYear || semester ? ( <section className="report-card-signatures mt-6">
<tfoot> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<tr className="border-t text-xs text-muted-foreground"> <SignatureBlock label={t("signatureClassTeacher")} />
<td className="py-2 pr-4" colSpan={6}> <SignatureBlock label={t("signatureParent")} />
{academicYear <SignatureBlock label={t("signaturePrincipal")} />
? `${t("academicYearLabel")}: ${academicYear}` </div>
: ""} <p className="mt-3 text-xs text-muted-foreground">
{academicYear && semester ? " / " : ""} {t("footerNote", { date: formatDateTime(new Date().toISOString()) })}
{semester ? `${t("semesterLabel")}: ${semester}` : ""} </p>
</td> </section>
</tr>
</tfoot>
) : null}
</table>
</div> </div>
); );
} }
/** 格式化分数为展示字符串,保留 1 位小数。 */ /** 学科成绩明细表:使用 rowSpan 合并同学科单元格,展示多记录展开。 */
function formatScore(score: number): string { function GradesTable({
if (!Number.isFinite(score)) return "--"; subjects,
return score.toFixed(1); }: {
subjects: StudentReportCardSubject[];
}): React.ReactElement {
const t = useTranslations("studentDomain.grades.reportCard");
return (
<Card className="border-2">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/20">
<tr>
<th className="p-2 text-left font-medium text-muted-foreground">
{t("colSubject")}
</th>
<th className="p-2 text-left font-medium text-muted-foreground">
{t("colAssessment")}
</th>
<th className="p-2 text-center font-medium text-muted-foreground">
{t("colType")}
</th>
<th className="p-2 text-right font-medium text-muted-foreground">
{t("colScore")}
</th>
<th className="p-2 text-right font-medium text-muted-foreground">
{t("colTotalScore")}
</th>
<th className="p-2 text-left font-medium text-muted-foreground">
{t("colRemark")}
</th>
<th className="p-2 text-center font-medium text-muted-foreground">
{t("colLevel")}
</th>
<th className="p-2 text-center font-medium text-muted-foreground">
{t("colRank")}
</th>
<th className="p-2 text-right font-medium text-muted-foreground">
{t("subjectAvgLabel")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{subjects.flatMap((subj) =>
expandSubjectRows(subj).map((row, idx) => (
<SubjectRow key={`${subj.subject}-${idx}`} row={row} />
)),
)}
</tbody>
</table>
</CardContent>
</Card>
);
} }
/** 格式化 0-1 的得分率为百分比字符串。 */ /** 单行渲染:根据 rowSpan 标记决定是否渲染学科汇总单元格。 */
function formatRate(rate: number): string { function SubjectRow({ row }: { row: SubjectTableRow }): React.ReactElement {
const t = useTranslations("studentDomain.grades.reportCard");
return (
<tr className="hover:bg-muted/20">
{row.subjectCell != null ? (
<td
className="border-r p-2 align-middle font-medium"
rowSpan={row.subjectCell.rowSpan}
>
{row.subjectCell.subject}
<div className="mt-1 text-xs text-muted-foreground">
{t("colScore")}{row.subjectCell.score.toFixed(1)} /{" "}
{row.subjectCell.totalScore.toFixed(1)}
</div>
<div className="text-xs text-muted-foreground">
{t("colScoreRate")}{formatPercent(row.subjectCell.scoreRate)}
</div>
</td>
) : null}
<td className="p-2 align-middle">{row.record?.title ?? "--"}</td>
<td className="p-2 text-center align-middle">
{row.record ? translateRecordType(row.record.type, t) : "--"}
</td>
<td className="p-2 text-right align-middle tabular-nums">
{row.record ? row.record.score.toFixed(1) : "--"}
</td>
<td className="p-2 text-right align-middle tabular-nums text-muted-foreground">
{row.record ? row.record.totalScore.toFixed(1) : "--"}
</td>
<td className="p-2 align-middle text-muted-foreground">
{row.record?.remark || "--"}
</td>
{row.subjectCell != null ? (
<>
<td
className="p-2 text-center align-middle"
rowSpan={row.subjectCell.rowSpan}
>
<LevelBadge level={row.subjectCell.level} />
</td>
<td
className="p-2 text-center align-middle tabular-nums"
rowSpan={row.subjectCell.rowSpan}
>
{formatRank(
row.subjectCell.rankInSubject,
row.subjectCell.totalStudentsInSubject,
t,
)}
</td>
<td
className="p-2 text-right align-middle tabular-nums text-muted-foreground"
rowSpan={row.subjectCell.rowSpan}
>
{row.subjectCell.subjectAvg != null
? row.subjectCell.subjectAvg.toFixed(1)
: "--"}
</td>
</>
) : null}
</tr>
);
}
/** 综合统计中的单格。 */
function SummaryStat({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="rounded-md border bg-card p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{value}</p>
</div>
);
}
/** 信息字段(学生基本信息区)。 */
function InfoField({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium">{value}</span>
</div>
);
}
/** 等级徽章。 */
function LevelBadge({ level }: { level: string }): React.ReactElement {
const cls = levelToBadgeClass(level);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{level || "--"}
</span>
);
}
/** 签名区块。 */
function SignatureBlock({ label }: { label: string }): React.ReactElement {
return (
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">{label}</p>
<div className="report-card-signature-line h-10 rounded-md border border-dashed" />
</div>
);
}
/** 报告卡综合统计。 */
interface ReportCardSummary {
subjectCount: number;
totalScore: number;
fullScore: number;
averageRate: number;
averageScore: number;
passRate: number;
excellentRate: number;
}
/** 学科单元格(用于 rowSpan 合并)。 */
interface SubjectCell {
subject: string;
score: number;
totalScore: number;
scoreRate: number;
level: string;
rankInSubject?: number;
totalStudentsInSubject?: number;
subjectAvg?: number;
rowSpan: number;
}
/** 表格行(学科首行携带 subjectCell后续行 subjectCell 为 null。 */
interface SubjectTableRow {
subjectCell: SubjectCell | null;
record: StudentReportCardRecord | null;
}
/** 将学科展开为多行(含 rowSpan 信息)。无记录时降级为单行空记录。 */
function expandSubjectRows(
subject: StudentReportCardSubject,
): SubjectTableRow[] {
const records = subject.records ?? [];
const rowSpan = records.length > 0 ? records.length : 1;
const subjectCell: SubjectCell = {
subject: subject.subject,
score: subject.score,
totalScore: subject.totalScore,
scoreRate: subject.scoreRate,
level: subject.level,
rankInSubject: subject.rankInSubject,
totalStudentsInSubject: subject.totalStudentsInSubject,
subjectAvg: subject.subjectAvg,
rowSpan,
};
if (records.length === 0) {
return [{ subjectCell, record: null }];
}
return records.map((record, idx) => ({
subjectCell: idx === 0 ? subjectCell : null,
record,
}));
}
/** 计算报告卡综合统计。 */
function computeSummary(
subjects: StudentReportCardSubject[],
): ReportCardSummary {
if (subjects.length === 0) {
return {
subjectCount: 0,
totalScore: 0,
fullScore: 0,
averageRate: 0,
averageScore: 0,
passRate: 0,
excellentRate: 0,
};
}
const totalScore = subjects.reduce((acc, s) => acc + s.score, 0);
const fullScore = subjects.reduce((acc, s) => acc + s.totalScore, 0);
const averageRate = fullScore > 0 ? totalScore / fullScore : 0;
const averageScore =
subjects.reduce((acc, s) => acc + s.scoreRate, 0) / subjects.length;
const passCount = subjects.filter(
(s) => s.scoreRate >= PASS_RATE_THRESHOLD,
).length;
const excellentCount = subjects.filter(
(s) => s.scoreRate >= EXCELLENT_RATE_THRESHOLD,
).length;
return {
subjectCount: subjects.length,
totalScore,
fullScore,
averageRate,
averageScore,
passRate: passCount / subjects.length,
excellentRate: excellentCount / subjects.length,
};
}
/** 根据等级返回 Tailwind 徽章类名。 */
function levelToBadgeClass(level: string): string {
const normalized = level.trim();
if (
normalized === "优秀" ||
normalized === "A" ||
normalized === "Excellent"
) {
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
}
if (normalized === "良好" || normalized === "B" || normalized === "Good") {
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
}
if (normalized === "合格" || normalized === "C" || normalized === "Pass") {
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
}
if (
normalized === "不合格" ||
normalized === "D" ||
normalized === "F" ||
normalized === "Fail"
) {
return "bg-destructive/10 text-destructive";
}
return "bg-muted text-muted-foreground";
}
/** 格式化 0-1 的比率为百分比字符串。 */
function formatPercent(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--"; if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`; return `${(rate * 100).toFixed(1)}%`;
} }
/** 根据得分率返回 Tailwind 文本语义类名。 */ /** 格式化排名为 "{rank} / {total}"。 */
function scoreRateToColorClass(rate: number): string { function formatRank(
if (!Number.isFinite(rate) || rate < 0 || rate > 1) { rank: number | undefined,
return "text-muted-foreground"; total: number | undefined,
} t: ReturnType<typeof useTranslations>,
if (rate >= 0.8) return "text-emerald-600"; ): string {
if (rate >= 0.6) return "text-amber-600"; if (rank == null || total == null) return "--";
return "text-destructive"; return t("rankFormat", { rank, total });
} }
/** 根据等级返回 Tailwind 文本语义类名。 */ /** 将记录类型映射为 i18n 文案。 */
function levelToColorClass(level: string): string { function translateRecordType(
switch (level) { type: string,
case "A": t: ReturnType<typeof useTranslations>,
return "text-emerald-600"; ): string {
case "B": switch (type) {
return "text-blue-600"; case "exam":
case "C": return t("typeExam");
return "text-amber-600"; case "homework":
case "D": return t("typeHomework");
return "text-destructive"; case "quiz":
return t("typeQuiz");
default: default:
return "text-muted-foreground"; return type || "--";
} }
} }
/** 格式化 ISO 日期为本地化日期时间。 */
function formatDateTime(isoDate: string | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN");
}

View File

@@ -7,22 +7,39 @@
* - studentHomework(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentHomework(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* *
* URL 状态:?status=xxx * URL 状态:?status=xxx
* 本地状态viewModecard/table、searchQ搜索词
*
* 视图:
* - 卡片视图:网格卡片,展示标题/学科/截止/状态/尝试次数/最新成绩/操作
* - 表格视图:保留原学科分组表格
*
* 子区域:每个学科下分"未答/已答"两组
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { BookOpen } from "lucide-react"; import {
BookOpen,
LayoutGrid,
SearchX,
Table as TableIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useStudentHomework, type StudentHomework } from "@/lib/api"; import { useStudentHomework, type StudentHomework } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { cn } from "@/shared/lib/utils";
type ViewMode = "card" | "table";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -36,20 +53,25 @@ export function StudentHomeworkListClient(): React.ReactElement {
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const statusFilter = searchParams.get("status") ?? ""; const statusFilter = searchParams.get("status") ?? "";
const [viewMode, setViewMode] = useState<ViewMode>("card");
const [searchQ, setSearchQ] = useState<string>("");
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useStudentHomework({ const { data, loading, error } = useStudentHomework({
status: statusFilter || undefined, status: statusFilter || undefined,
}); });
// 客户端二次筛选status—— 后端补齐契约后改服务端筛选 // 客户端二次筛选status + search)—— 后端补齐契约后改服务端筛选
const filteredItems = useMemo<StudentHomework[]>(() => { const filteredItems = useMemo<StudentHomework[]>(() => {
const items = data ?? []; const items = data ?? [];
if (!statusFilter) return items; const q = searchQ.trim().toLowerCase();
return items.filter( return items.filter((item) => {
(item) => normalizeStatus(item.status) === statusFilter, const matchStatus =
); !statusFilter || normalizeStatus(item.status) === statusFilter;
}, [data, statusFilter]); const matchSearch = !q || item.title.toLowerCase().includes(q);
return matchStatus && matchSearch;
});
}, [data, statusFilter, searchQ]);
// 按学科分组 // 按学科分组
const grouped = useMemo<Map<string, StudentHomework[]>>(() => { const grouped = useMemo<Map<string, StudentHomework[]>>(() => {
@@ -83,12 +105,13 @@ export function StudentHomeworkListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
return ( const filtersNode = (
<ListPageShell <>
title={t("title")} <FilterSearchInput
description={t("description")} placeholder={t("searchPlaceholder")}
icon={<BookOpen className="size-6" />} value={searchQ}
filters={ onChange={setSearchQ}
/>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)} onChange={(e) => updateQuery("status", e.target.value)}
@@ -99,15 +122,30 @@ export function StudentHomeworkListClient(): React.ReactElement {
<option value="pending">{t("statusPending")}</option> <option value="pending">{t("statusPending")}</option>
<option value="submitted">{t("statusSubmitted")}</option> <option value="submitted">{t("statusSubmitted")}</option>
<option value="graded">{t("statusGraded")}</option> <option value="graded">{t("statusGraded")}</option>
<option value="overdue">{t("statusOverdue")}</option>
</select> </select>
} <ViewModeToggle viewMode={viewMode} onChange={setViewMode} />
</>
);
const hasResults = filteredItems.length > 0;
const showEmpty = !loading && !hasResults;
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BookOpen className="size-6" />}
filters={filtersNode}
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading} empty={showEmpty && !searchQ && !statusFilter}
emptyNode={ emptyNode={
<div className="flex min-h-[300px] flex-col items-center justify-center gap-2 p-8 text-center"> <EmptyState
<p className="text-lg font-semibold">{t("emptyTitle")}</p> icon={BookOpen}
</div> title={t("emptyTitle")}
description={t("emptyDesc")}
/>
} }
errorNode={errorNode} errorNode={errorNode}
pagination={ pagination={
@@ -116,71 +154,338 @@ export function StudentHomeworkListClient(): React.ReactElement {
</div> </div>
} }
> >
{showEmpty && (searchQ || statusFilter) ? (
<EmptyState
icon={SearchX}
title={t("noResults")}
description={t("emptyDesc")}
/>
) : null}
{hasResults ? (
<div className="space-y-6"> <div className="space-y-6">
{Array.from(grouped.entries()).map(([subject, items]) => ( {Array.from(grouped.entries()).map(([subject, items]) => (
<HomeworkSubjectGroup key={subject} subject={subject} items={items} /> <HomeworkSubjectGroup
key={subject}
subject={subject}
items={items}
viewMode={viewMode}
/>
))} ))}
</div> </div>
) : null}
</ListPageShell> </ListPageShell>
); );
} }
/** /**
* 学科分组(标题 + 表格)。 * 视图切换器(卡片 / 表格)。
*/
function ViewModeToggle({
viewMode,
onChange,
}: {
viewMode: ViewMode;
onChange: (mode: ViewMode) => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.homework.list");
return (
<div
className="inline-flex h-9 items-center rounded-lg border border-input bg-muted p-1"
role="group"
aria-label="view mode"
>
<button
type="button"
onClick={() => onChange("card")}
aria-pressed={viewMode === "card"}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
viewMode === "card"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="size-3.5" />
{t("viewCard")}
</button>
<button
type="button"
onClick={() => onChange("table")}
aria-pressed={viewMode === "table"}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
viewMode === "table"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<TableIcon className="size-3.5" />
{t("viewTable")}
</button>
</div>
);
}
/**
* 学科分组:标题 + 未答/已答子区域。
*/ */
function HomeworkSubjectGroup({ function HomeworkSubjectGroup({
subject, subject,
items, items,
viewMode,
}: { }: {
subject: string; subject: string;
items: StudentHomework[]; items: StudentHomework[];
viewMode: ViewMode;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.homework.list"); const t = useTranslations("studentDomain.homework.list");
// 按答题状态分两组:未答 / 已答
const { unanswered, answered } = useMemo(() => {
const ua: StudentHomework[] = [];
const ad: StudentHomework[] = [];
for (const item of items) {
const normalized = normalizeStatus(item.status);
if (normalized === "submitted" || normalized === "graded") {
ad.push(item);
} else {
ua.push(item);
}
}
return { unanswered: ua, answered: ad };
}, [items]);
return ( return (
<section className="rounded-xl border bg-card"> <section className="rounded-xl border bg-card">
<header className="border-b bg-muted/30 px-4 py-3"> <header className="border-b bg-muted/30 px-4 py-3">
<h2 className="text-sm font-semibold">{subject}</h2> <h2 className="text-sm font-semibold">{subject}</h2>
</header> </header>
<div className="overflow-x-auto"> <div className="space-y-4 p-4">
{unanswered.length > 0 ? (
<HomeworkSubGroup
label={t("groupUnanswered")}
items={unanswered}
viewMode={viewMode}
/>
) : null}
{answered.length > 0 ? (
<HomeworkSubGroup
label={t("groupAnswered")}
items={answered}
viewMode={viewMode}
/>
) : null}
</div>
</section>
);
}
/**
* 子区域:标题 + 卡片网格/表格。
*/
function HomeworkSubGroup({
label,
items,
viewMode,
}: {
label: string;
items: StudentHomework[];
viewMode: ViewMode;
}): React.ReactElement {
return (
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label} ({items.length})
</h3>
{viewMode === "card" ? (
<HomeworkCardGrid items={items} />
) : (
<HomeworkTable items={items} />
)}
</div>
);
}
/**
* 卡片网格视图。
*/
function HomeworkCardGrid({
items,
}: {
items: StudentHomework[];
}): React.ReactElement {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((hw) => (
<HomeworkCard key={hw.id} hw={hw} />
))}
</div>
);
}
/**
* 单个作业卡片。
*/
function HomeworkCard({ hw }: { hw: StudentHomework }): React.ReactElement {
const t = useTranslations("studentDomain.homework.list");
const normalized = normalizeStatus(hw.status);
const isOverdue = normalized === "overdue";
const isAnswered = normalized === "submitted" || normalized === "graded";
const isGraded = normalized === "graded";
const submitHref = `/shell/student/homework/${hw.id}/submit`;
const analysisHref = `/shell/student/homework/${hw.id}/analysis`;
const actionLabel = isAnswered
? isGraded
? t("actionReview")
: t("actionView")
: normalized === "in_progress"
? t("actionContinue")
: t("actionStart");
return (
<article className="flex flex-col gap-3 rounded-lg border bg-background p-4 transition-shadow hover:shadow-sm">
<header className="flex items-start justify-between gap-2">
<Link
href={submitHref}
className="line-clamp-2 text-sm font-semibold hover:underline"
>
{hw.title}
</Link>
{isOverdue ? (
<span className="inline-flex h-6 shrink-0 items-center rounded-full bg-destructive/15 px-2 text-xs font-medium text-destructive">
{t("overdueBadge")}
</span>
) : null}
</header>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{hw.subject}</span>
<span aria-hidden>·</span>
<span className="font-mono">{hw.dueDate}</span>
</div>
<div className="flex items-center gap-2">
<HomeworkStatusBadge status={hw.status} />
</div>
<dl className="grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{t("attempts")}</dt>
<dd className="font-mono">
{t("attemptsValue", { used: hw.attemptsUsed, max: hw.maxAttempts })}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{t("latestScore")}</dt>
<dd className="font-mono">
{hw.latestScore !== null ? hw.latestScore : "--"}
</dd>
</div>
</dl>
<footer className="mt-auto flex items-center justify-end gap-2 border-t pt-2">
<Link
href={isGraded ? analysisHref : submitHref}
className="inline-flex h-7 items-center rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{actionLabel}
</Link>
{isGraded ? (
<Link
href={analysisHref}
className="inline-flex h-7 items-center rounded-md border border-input bg-background px-2 text-xs font-medium transition-colors hover:bg-muted"
>
{t("viewAnalysis")}
</Link>
) : null}
</footer>
</article>
);
}
/**
* 表格视图。
*/
function HomeworkTable({
items,
}: {
items: StudentHomework[];
}): React.ReactElement {
const t = useTranslations("studentDomain.homework.list");
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b text-left text-muted-foreground"> <thead className="border-b bg-muted/30 text-left text-muted-foreground">
<tr> <tr>
<th className="p-3 font-medium">{t("colTitle")}</th> <th className="p-3 font-medium">{t("colTitle")}</th>
<th className="p-3 font-medium">{t("colDueDate")}</th> <th className="p-3 font-medium">{t("colDueDate")}</th>
<th className="p-3 font-medium">{t("attempts")}</th>
<th className="p-3 font-medium">{t("colStatus")}</th> <th className="p-3 font-medium">{t("colStatus")}</th>
<th className="p-3 text-right font-medium">{t("colScore")}</th> <th className="p-3 text-right font-medium">{t("latestScore")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th> <th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{items.map((hw) => ( {items.map((hw) => (
<tr key={hw.id} className="hover:bg-muted/30"> <HomeworkTableRow key={hw.id} hw={hw} />
))}
</tbody>
</table>
</div>
);
}
/**
* 表格行。
*/
function HomeworkTableRow({ hw }: { hw: StudentHomework }): React.ReactElement {
const t = useTranslations("studentDomain.homework.list");
const normalized = normalizeStatus(hw.status);
const isAnswered = normalized === "submitted" || normalized === "graded";
const isGraded = normalized === "graded";
const submitHref = `/shell/student/homework/${hw.id}/submit`;
const analysisHref = `/shell/student/homework/${hw.id}/analysis`;
const actionLabel = isAnswered
? isGraded
? t("actionReview")
: t("actionView")
: normalized === "in_progress"
? t("actionContinue")
: t("actionStart");
return (
<tr className="hover:bg-muted/30">
<td className="p-3"> <td className="p-3">
<Link <Link href={submitHref} className="font-medium hover:underline">
href={`/shell/student/homework/${hw.id}/submit`}
className="font-medium hover:underline"
>
{hw.title} {hw.title}
</Link> </Link>
</td> </td>
<td className="p-3 font-mono text-xs">{hw.dueDate}</td> <td className="p-3 font-mono text-xs">{hw.dueDate}</td>
<td className="p-3 font-mono text-xs">
{t("attemptsValue", { used: hw.attemptsUsed, max: hw.maxAttempts })}
</td>
<td className="p-3"> <td className="p-3">
<HomeworkStatusBadge status={hw.status} /> <HomeworkStatusBadge status={hw.status} />
</td> </td>
<td className="p-3 text-right font-mono"> <td className="p-3 text-right font-mono">
{hw.score !== null ? hw.score : "--"} {hw.latestScore !== null ? hw.latestScore : "--"}
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Link <Link
href={`/shell/student/homework/${hw.id}/submit`} href={isGraded ? analysisHref : submitHref}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
{t("submitHomework")} {actionLabel}
</Link> </Link>
{normalizeStatus(hw.status) === "graded" ? ( {isGraded ? (
<Link <Link
href={`/shell/student/homework/${hw.id}/analysis`} href={analysisHref}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
{t("viewAnalysis")} {t("viewAnalysis")}
@@ -189,11 +494,6 @@ function HomeworkSubjectGroup({
</div> </div>
</td> </td>
</tr> </tr>
))}
</tbody>
</table>
</div>
</section>
); );
} }
@@ -205,39 +505,47 @@ function HomeworkStatusBadge({
}: { }: {
status: string; status: string;
}): React.ReactElement { }): React.ReactElement {
const cls = homeworkStatusToBadgeClass(status); const t = useTranslations("studentDomain.homework.list");
const normalized = normalizeStatus(status);
const cls = homeworkStatusToBadgeClass(normalized);
const label = homeworkStatusLabel(normalized, status, t);
return ( return (
<span <span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`} className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
> >
{status} {label}
</span> </span>
); );
} }
/** /**
* 状态归一化(兼容 MSW 中文与未来英文枚举)。 * 状态归一化(兼容 MSW 中文与未来英文枚举)。
* pending / submitted / graded / overdue * pending / submitted / graded / overdue / in_progress / not_started
*/ */
function normalizeStatus(status: string): string { function normalizeStatus(status: string): string {
const s = status.toLowerCase(); const s = status.toLowerCase();
if (s.includes("pend") || s.includes("待提交")) return "pending"; if (s.includes("overdue") || s.includes("逾期")) return "overdue";
if (s.includes("submit") || s.includes("已提交")) return "submitted";
if (s.includes("grad") || s.includes("已完成") || s.includes("已批改")) { if (s.includes("grad") || s.includes("已完成") || s.includes("已批改")) {
return "graded"; return "graded";
} }
if (s.includes("overdue") || s.includes("逾期")) return "overdue"; if (s.includes("submit") || s.includes("已提交")) return "submitted";
if (s.includes("in_progress") || s.includes("进行中")) return "in_progress";
if (s.includes("not_started") || s.includes("未开始")) return "not_started";
if (s.includes("pend") || s.includes("待提交")) return "pending";
return s; return s;
} }
/** /**
* 状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。 * 状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。
*/ */
function homeworkStatusToBadgeClass(status: string): string { function homeworkStatusToBadgeClass(normalized: string): string {
const normalized = normalizeStatus(status);
switch (normalized) { switch (normalized) {
case "not_started":
return "bg-muted text-muted-foreground";
case "pending": case "pending":
return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"; return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200";
case "in_progress":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
case "submitted": case "submitted":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"; return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
case "graded": case "graded":
@@ -248,3 +556,29 @@ function homeworkStatusToBadgeClass(status: string): string {
return "bg-muted text-muted-foreground"; return "bg-muted text-muted-foreground";
} }
} }
/**
* 状态 → 本地化标签。
*/
function homeworkStatusLabel(
normalized: string,
raw: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (normalized) {
case "not_started":
return t("statusNotStarted");
case "pending":
return t("statusPending");
case "in_progress":
return t("statusInProgress");
case "submitted":
return t("statusSubmitted");
case "graded":
return t("statusGraded");
case "overdue":
return t("statusOverdue");
default:
return raw;
}
}

View File

@@ -4,13 +4,13 @@
* 学生作业作答工作台页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2 * 学生作业作答工作台页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
* *
* 数据契约(@contract-pending 全 MSW * 数据契约(@contract-pending 全 MSW
* - studentHomeworkSubmit(id) ❌ → MSW 兜底 * - studentHomeworkSubmit(id) -> MSW 兜底
* - submitStudentHomework(input) mutation ❌ → MSW 兜底 * - submitStudentHomework(input) mutation -> MSW 兜底
* - studentHomeworkAnalysis(id) -> MSW 兜底ReviewView 复用)
* *
* 三栏布局WorkbenchPageShell * 状态分流(依据 submissionStatus
* - left题目导航题号 + 答题状态,点击跳转) * - not_started / in_progress / undefined -> 作答工作台TakeView三栏布局
* - center题目列表 + 答案输入区 * - submitted / graded -> 只读复盘视图ReviewView详情页布局
* - right自动保存提示 + 答题统计 + 提交按钮(二次确认)
* *
* 答案本地暂存localStorage key `homework-answers-${id}`,防刷新丢失。 * 答案本地暂存localStorage key `homework-answers-${id}`,防刷新丢失。
* *
@@ -18,25 +18,55 @@
* *
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { CheckCircle2, ClipboardList, Circle } from "lucide-react"; import {
BarChart3,
Check,
CheckCircle2,
ClipboardList,
Circle,
X,
} from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
useStudentHomeworkAnalysis,
useStudentHomeworkSubmit, useStudentHomeworkSubmit,
useSubmitStudentHomework, useSubmitStudentHomework,
type StudentHomeworkAnalysis,
type StudentHomeworkAnalysisQuestion,
type StudentHomeworkSubmit, type StudentHomeworkSubmit,
type StudentHomeworkSubmitQuestion, type StudentHomeworkSubmitQuestion,
} from "@/lib/api"; } from "@/lib/api";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
WorkbenchPageShell, WorkbenchPageShell,
WorkbenchPageSkeleton, WorkbenchPageSkeleton,
WorkbenchPanel, WorkbenchPanel,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
/** 已提交/已批改状态归一化后需切换到 ReviewView 的取值集合。 */
const REVIEW_STATUSES = new Set(["submitted", "graded"]);
/** 将后端 status 字段归一化为小写英文枚举(兼容 MSW 中文与未来英文枚举)。 */
function normalizeSubmissionStatus(status: string | undefined): string {
if (!status) return "";
const s = status.toLowerCase();
if (s.includes("submit") || s.includes("已提交")) return "submitted";
if (s.includes("grad") || s.includes("已批改") || s.includes("已完成")) {
return "graded";
}
if (s.includes("not_started") || s.includes("未开始")) return "not_started";
if (s.includes("in_progress") || s.includes("进行中")) return "in_progress";
return s;
}
/** /**
* 作答工作台客户端主体。需由 server page 包裹在 <Suspense> 中。 * 作答工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
*/ */
@@ -46,29 +76,61 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? ""; const homeworkId = params?.id ?? "";
const { data, loading, error } = useStudentHomeworkSubmit(homeworkId); const { data, loading, error } = useStudentHomeworkSubmit(homeworkId);
const normalizedStatus = normalizeSubmissionStatus(data?.submissionStatus);
const isReviewMode = REVIEW_STATUSES.has(normalizedStatus);
if (isReviewMode && data) {
return <ReviewView homeworkId={homeworkId} submission={data} />;
}
return (
<TakeView
homeworkId={homeworkId}
data={data}
loading={loading}
error={error}
t={t}
tCommon={tCommon}
router={router}
/>
);
}
interface TakeViewProps {
homeworkId: string;
data: StudentHomeworkSubmit | undefined;
loading: boolean;
error: unknown;
t: ReturnType<typeof useTranslations>;
tCommon: ReturnType<typeof useTranslations>;
router: ReturnType<typeof useRouter>;
}
function TakeView({
homeworkId,
data,
loading,
error,
t,
tCommon,
router,
}: TakeViewProps): React.ReactElement {
const [answers, setAnswers] = useState<Record<string, string>>({}); const [answers, setAnswers] = useState<Record<string, string>>({});
const [confirmOpen, setConfirmOpen] = useState<boolean>(false); const [confirmOpen, setConfirmOpen] = useState<boolean>(false);
const [activeQuestion, setActiveQuestion] = useState<number>(1); const [activeQuestion, setActiveQuestion] = useState<number>(1);
const initialized = useRef<boolean>(false); const initialized = useRef<boolean>(false);
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map()); const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const storageKey = `homework-answers-${homeworkId}`; const storageKey = `homework-answers-${homeworkId}`;
// 首次拿到数据时初始化答案localStorage 优先,回退到后端已存答案)
useEffect(() => { useEffect(() => {
if (!data || initialized.current) return; if (!data || initialized.current) return;
initialized.current = true; initialized.current = true;
const saved = localStorage.getItem(storageKey); const saved = localStorage.getItem(storageKey);
if (saved) { if (saved) {
try { try {
setAnswers(JSON.parse(saved) as Record<string, string>); setAnswers(JSON.parse(saved) as Record<string, string>);
return; return;
} catch { } catch (err) {
// 存储损坏,回退到后端数据 console.warn("[portal-shell] restore homework answers failed:", err);
} }
} }
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
@@ -80,7 +142,6 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
setAnswers(initial); setAnswers(initial);
}, [data, storageKey]); }, [data, storageKey]);
// 答案变化时自动保存到 localStorage
useEffect(() => { useEffect(() => {
if (!initialized.current) return; if (!initialized.current) return;
localStorage.setItem(storageKey, JSON.stringify(answers)); localStorage.setItem(storageKey, JSON.stringify(answers));
@@ -149,6 +210,8 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
answers={answers} answers={answers}
activeQuestion={activeQuestion} activeQuestion={activeQuestion}
onNavigate={scrollToQuestion} onNavigate={scrollToQuestion}
maxAttempts={data.maxAttempts}
attemptsUsed={data.attemptsUsed}
/> />
) : null ) : null
} }
@@ -190,22 +253,32 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
); );
} }
/**
* 左栏:题目导航(题号 + 答题状态)。
*/
function QuestionNavPanel({ function QuestionNavPanel({
questions, questions,
answers, answers,
activeQuestion, activeQuestion,
onNavigate, onNavigate,
maxAttempts,
attemptsUsed,
}: { }: {
questions: StudentHomeworkSubmitQuestion[]; questions: StudentHomeworkSubmitQuestion[];
answers: Record<string, string>; answers: Record<string, string>;
activeQuestion: number; activeQuestion: number;
onNavigate: (questionNo: number) => void; onNavigate: (questionNo: number) => void;
maxAttempts?: number;
attemptsUsed?: number;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.homework.submit");
return ( return (
<WorkbenchPanel title="题目导航"> <WorkbenchPanel title={t("questionNav")}>
{typeof maxAttempts === "number" ? (
<div className="mb-3 rounded-md border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">{t("attemptsUsed")}</p>
<p className="mt-1 text-sm font-medium">
{t("attemptsValue", { used: attemptsUsed ?? 0, max: maxAttempts })}
</p>
</div>
) : null}
<ol className="space-y-1"> <ol className="space-y-1">
{questions.map((q) => { {questions.map((q) => {
const answered = const answered =
@@ -237,9 +310,6 @@ function QuestionNavPanel({
); );
} }
/**
* 中栏:题目列表 + 答案输入区。
*/
function QuestionListPanel({ function QuestionListPanel({
data, data,
answers, answers,
@@ -271,9 +341,6 @@ function QuestionListPanel({
); );
} }
/**
* 单题卡片(题干 + 答案输入)。
*/
function QuestionCard({ function QuestionCard({
question, question,
answer, answer,
@@ -287,9 +354,9 @@ function QuestionCard({
questionRefs: React.MutableRefObject<Map<number, HTMLDivElement>>; questionRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
onFocus: () => void; onFocus: () => void;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.homework.submit");
const isChoice = const isChoice =
question.type === "single_choice" && question.options.length > 0; question.type === "single_choice" && question.options.length > 0;
return ( return (
<div <div
ref={(el) => { ref={(el) => {
@@ -304,7 +371,6 @@ function QuestionCard({
</span> </span>
<p className="flex-1 text-sm">{question.question}</p> <p className="flex-1 text-sm">{question.question}</p>
</div> </div>
{isChoice ? ( {isChoice ? (
<div className="space-y-2 pl-8"> <div className="space-y-2 pl-8">
{question.options.map((opt, idx) => ( {question.options.map((opt, idx) => (
@@ -325,21 +391,20 @@ function QuestionCard({
))} ))}
</div> </div>
) : ( ) : (
<div className="pl-8">
<textarea <textarea
value={answer} value={answer}
onChange={(e) => onAnswerChange(e.target.value)} onChange={(e) => onAnswerChange(e.target.value)}
placeholder="请输入答案..." placeholder={t("answerPlaceholder")}
rows={4} rows={4}
className="ml-8 w-[calc(100%-2rem)] rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2"
/> />
</div>
)} )}
</div> </div>
); );
} }
/**
* 右栏:自动保存提示 + 答题统计 + 提交按钮。
*/
function SubmitPanel({ function SubmitPanel({
answeredCount, answeredCount,
totalQuestions, totalQuestions,
@@ -355,15 +420,15 @@ function SubmitPanel({
submitDisabled: boolean; submitDisabled: boolean;
onSubmitClick: () => void; onSubmitClick: () => void;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.homework.submit");
return ( return (
<WorkbenchPanel title="提交"> <WorkbenchPanel title={t("submitPanelTitle")}>
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border bg-background p-3"> <div className="rounded-md border bg-background p-3">
<p className="text-xs text-emerald-600">{autoSaveTip}</p> <p className="text-xs text-emerald-600">{autoSaveTip}</p>
</div> </div>
<div className="rounded-md border bg-background p-4"> <div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground">{t("answerProgress")}</p>
<p className="mt-1 text-2xl font-semibold"> <p className="mt-1 text-2xl font-semibold">
{answeredCount} {answeredCount}
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
@@ -380,7 +445,6 @@ function SubmitPanel({
/> />
</div> </div>
</div> </div>
<Button <Button
type="button" type="button"
className="w-full" className="w-full"
@@ -394,9 +458,6 @@ function SubmitPanel({
); );
} }
/**
* 提交确认对话框(二次确认)。
*/
function ConfirmDialog({ function ConfirmDialog({
message, message,
cancelLabel, cancelLabel,
@@ -432,3 +493,236 @@ function ConfirmDialog({
</div> </div>
); );
} }
interface ReviewViewProps {
homeworkId: string;
submission: StudentHomeworkSubmit;
}
function ReviewView({
homeworkId,
submission,
}: ReviewViewProps): React.ReactElement {
const t = useTranslations("studentDomain.homework.submit");
const tAnalysis = useTranslations("studentDomain.homework.analysis");
const tCommon = useTranslations("common");
const {
data: analysis,
loading,
error,
} = useStudentHomeworkAnalysis(homeworkId);
const normalizedStatus = normalizeSubmissionStatus(
submission.submissionStatus,
);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={submission.title}
description={submission.dueDate}
icon={<ClipboardList className="size-6" />}
backHref="/shell/student/homework"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
actions={
<Link
href={`/shell/student/homework/${homeworkId}/analysis`}
className="inline-flex h-9 items-center gap-1 rounded-md border border-input bg-background px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
>
<BarChart3 className="size-4" />
{t("viewAnalysis")}
</Link>
}
>
<ReviewBody
submission={submission}
analysis={analysis}
normalizedStatus={normalizedStatus}
t={t}
tAnalysis={tAnalysis}
/>
</DetailPageShell>
);
}
function ReviewBody({
submission,
analysis,
normalizedStatus,
t,
tAnalysis,
}: {
submission: StudentHomeworkSubmit;
analysis: StudentHomeworkAnalysis | undefined;
normalizedStatus: string;
t: ReturnType<typeof useTranslations>;
tAnalysis: ReturnType<typeof useTranslations>;
}): React.ReactElement {
return (
<>
<DetailSection title={t("reviewTitle")}>
<DetailField label={t("fieldTitle")} value={submission.title} />
<DetailField label={t("fieldDueDate")} value={submission.dueDate} />
<DetailField
label={t("fieldSubmittedAt")}
value={submission.submittedAt ?? "--"}
/>
<DetailField
label={t("fieldStatus")}
value={<ReviewStatusBadge status={normalizedStatus} />}
/>
{analysis ? (
<>
<DetailField
label={t("fieldScore")}
value={`${analysis.score} / ${analysis.totalScore}`}
/>
<DetailField
label={tAnalysis("fieldScoreRate")}
value={`${(analysis.scoreRate * 100).toFixed(1)}%`}
/>
</>
) : null}
{typeof submission.maxAttempts === "number" ? (
<DetailField
label={t("maxAttempts")}
value={t("attemptsValue", {
used: submission.attemptsUsed ?? 0,
max: submission.maxAttempts,
})}
/>
) : null}
</DetailSection>
{analysis ? (
<DetailSection title={tAnalysis("sectionQuestions")}>
<ReviewQuestionTable
submission={submission}
analysisQuestions={analysis.questions}
t={t}
tAnalysis={tAnalysis}
/>
</DetailSection>
) : null}
<DetailSection title={t("teacherComment")}>
{submission.feedback ? (
<p className="whitespace-pre-wrap text-sm">{submission.feedback}</p>
) : (
<p className="text-sm text-muted-foreground">{t("noComment")}</p>
)}
</DetailSection>
</>
);
}
function ReviewQuestionTable({
submission,
analysisQuestions,
t,
tAnalysis,
}: {
submission: StudentHomeworkSubmit;
analysisQuestions: StudentHomeworkAnalysisQuestion[];
t: ReturnType<typeof useTranslations>;
tAnalysis: ReturnType<typeof useTranslations>;
}): React.ReactElement {
const stemByNo = new Map<number, string>();
for (const q of submission.questions) {
stemByNo.set(q.questionNo, q.question);
}
if (analysisQuestions.length === 0) {
return <p className="text-sm text-muted-foreground">--</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">
{tAnalysis("colQuestionNo")}
</th>
<th className="py-2 pr-4 font-medium">{t("questionStem")}</th>
<th className="py-2 pr-4 font-medium">
{tAnalysis("colYourAnswer")}
</th>
<th className="py-2 pr-4 font-medium">
{tAnalysis("colCorrectAnswer")}
</th>
<th className="py-2 pr-4 text-right font-medium">
{tAnalysis("colScore")}
</th>
<th className="py-2 pr-4 text-center font-medium">
{tAnalysis("colIsCorrect")}
</th>
</tr>
</thead>
<tbody>
{analysisQuestions.map((q) => {
const stem = stemByNo.get(q.questionNo) ?? "--";
return (
<tr key={q.questionNo} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{q.questionNo}</td>
<td className="py-2 pr-4">{stem}</td>
<td className="py-2 pr-4">{q.yourAnswer || "--"}</td>
<td className="py-2 pr-4">{q.correctAnswer}</td>
<td className="py-2 pr-4 text-right font-mono">{q.score}</td>
<td className="py-2 pr-4 text-center">
{q.isCorrect ? (
<span className="inline-flex items-center gap-1 text-emerald-600">
<Check className="size-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-destructive">
<X className="size-4" />
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function ReviewStatusBadge({ status }: { status: string }): React.ReactElement {
const cls = reviewStatusToBadgeClass(status);
const label = reviewStatusToLabel(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
function reviewStatusToBadgeClass(status: string): string {
switch (status) {
case "submitted":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
case "graded":
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200";
default:
return "bg-muted text-muted-foreground";
}
}
function reviewStatusToLabel(status: string): string {
switch (status) {
case "submitted":
return "Submitted";
case "graded":
return "Graded";
default:
return status || "--";
}
}

View File

@@ -8,17 +8,21 @@
* - studentLearningCenter ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentLearningCenter ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* *
* 复刻 CICD src/app/(dashboard)/student/learning/page.tsx
* - 3 张导航卡片(课程 / 作业 / 教材)
* - 每张卡片含图标 + 标题 + 描述 + 统计 + 箭头
* - 学生身份校验无身份data 为 null 且非 loading/error显示 EmptyState
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { import {
BookOpen,
ClipboardList, ClipboardList,
GraduationCap, GraduationCap,
AlertCircle, Library,
PencilLine,
ChevronRight, ChevronRight,
UserX,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -39,7 +43,7 @@ interface LearningCardConfig {
titleKey: string; titleKey: string;
descKey: string; descKey: string;
href: string; href: string;
stat: number | string; stat: string;
} }
/** /**
@@ -66,6 +70,9 @@ export function StudentLearningCenterClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
// 学生身份校验data 为 null 且非 loading/error 时视为无学生身份
const noStudent = !loading && !error && !data;
const cards: LearningCardConfig[] = [ const cards: LearningCardConfig[] = [
{ {
key: "courses", key: "courses",
@@ -73,7 +80,7 @@ export function StudentLearningCenterClient(): React.ReactElement {
titleKey: "cardCourses", titleKey: "cardCourses",
descKey: "cardCoursesDesc", descKey: "cardCoursesDesc",
href: "/shell/student/courses", href: "/shell/student/courses",
stat: data?.coursesCount ?? 0, stat: t("statCourses", { count: data?.coursesCount ?? 0 }),
}, },
{ {
key: "homework", key: "homework",
@@ -81,40 +88,27 @@ export function StudentLearningCenterClient(): React.ReactElement {
titleKey: "cardHomework", titleKey: "cardHomework",
descKey: "cardHomeworkDesc", descKey: "cardHomeworkDesc",
href: "/shell/student/homework", href: "/shell/student/homework",
stat: stat: t("statHomework", {
data != null pending: data?.homeworkPending ?? 0,
? `${data.homeworkPending}/${data.homeworkDueSoon}` dueSoon: data?.homeworkDueSoon ?? 0,
: "0/0", }),
}, },
{ {
key: "textbooks", key: "textbooks",
icon: BookOpen, icon: Library,
titleKey: "cardTextbooks", titleKey: "cardTextbooks",
descKey: "cardTextbooksDesc", descKey: "cardTextbooksDesc",
href: "/shell/student/textbooks", href: "/shell/student/textbooks",
stat: data?.textbooksCount ?? 0, stat: t("statTextbooks", { count: data?.textbooksCount ?? 0 }),
},
{
key: "practice",
icon: PencilLine,
titleKey: "cardPractice",
descKey: "cardPracticeDesc",
href: "/shell/student/practice",
stat: "—",
},
{
key: "errorBook",
icon: AlertCircle,
titleKey: "cardErrorBook",
descKey: "cardErrorBookDesc",
href: "/shell/student/error-book",
stat: "—",
}, },
]; ];
const emptyNode = const emptyNode = noStudent ? (
!loading && !error && !data ? ( <EmptyState
<EmptyState icon={BookOpen} title={t("emptyTitle")} /> icon={UserX}
title={t("noStudentTitle")}
description={t("noStudentDesc")}
/>
) : undefined; ) : undefined;
return ( return (
@@ -163,7 +157,7 @@ function LearningCard({
<h3 className="text-base font-semibold">{t(config.titleKey)}</h3> <h3 className="text-base font-semibold">{t(config.titleKey)}</h3>
<p className="text-sm text-muted-foreground">{t(config.descKey)}</p> <p className="text-sm text-muted-foreground">{t(config.descKey)}</p>
</div> </div>
<div className="mt-auto text-2xl font-bold tracking-tight tabular-nums"> <div className="mt-auto text-sm font-medium tabular-nums">
{config.stat} {config.stat}
</div> </div>
</Link> </Link>

View File

@@ -4,35 +4,37 @@
* 学生在线请假页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3 * 学生在线请假页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3
* *
* 数据契约(@contract-pending 全 MSW * 数据契约(@contract-pending 全 MSW
* - studentLeave❌ schema 无此字段 → MSW 兜底 * - studentLeave❌ schema 无此字段 → MSW 兜底(支持分页 page/pageSize
* - submitLeaveRequest mutation❌ schema 无此字段 → MSW 兜底 * - submitLeaveRequest mutation❌ schema 无此字段 → MSW 兜底
* - studentClasses用于班级下拉默认值❌ schema 无此字段 → MSW 兜底 * - studentClasses用于班级下拉默认值❌ schema 无此字段 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-leave * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-leave
* *
* 组成:
* - LeaveRequestForm表单 + 校验 + 提交(独立组件)
* - LeaveRequestList列表 + 分页 + 空态(独立组件)
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty记录空态 * 三态规范§11.3 DoDloading骨架/ error局部降级/ empty记录空态
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
*/ */
import { CalendarClock } from "lucide-react"; import { ArrowLeft, CalendarClock } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { import { useStudentClasses, useStudentLeave } from "@/lib/api";
useStudentClasses,
useStudentLeave,
useSubmitLeaveRequest,
type LeaveRequestInput,
type StudentLeaveItem,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { import {
DetailPageShell, DetailPageShell,
DetailPageSkeleton, DetailPageSkeleton,
DetailSection, DetailSection,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { cn } from "@/shared/lib/utils"; import { LeaveRequestForm } from "./leave-request-form";
import { LeaveRequestList } from "./leave-request-list";
/** 默认每页记录数 */
const DEFAULT_PAGE_SIZE = 20;
/** /**
* 在线请假客户端主体。需由 server page 包裹在 <Suspense> 中。 * 在线请假客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -41,81 +43,42 @@ export function StudentLeaveClient(): React.ReactElement {
const t = useTranslations("studentDomain.leave"); const t = useTranslations("studentDomain.leave");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
// @contract-pendingMSW 兜底 const [page, setPage] = useState(1);
const { data: leaveItems, loading, error, refetch } = useStudentLeave(); const [pageSize] = useState(DEFAULT_PAGE_SIZE);
// @contract-pendingMSW 兜底(班级下拉默认值来源)
// @contract-pendingMSW 兜底(支持分页)
const {
data: leaveResult,
loading,
error,
refetch,
} = useStudentLeave({ page, pageSize });
// @contract-pendingMSW 兜底(用于自动写入 classId由 LeaveRequestForm 内部消费)
const { data: classes } = useStudentClasses(); const { data: classes } = useStudentClasses();
// @contract-pendingMSW 兜底
const { run: submitLeave, loading: submitting } = useSubmitLeaveRequest();
const [classId, setClassId] = useState(""); // 自动选择第一个活跃班级作为 defaultClassId透传给表单
const [startDate, setStartDate] = useState(""); const defaultClassId = (classes ?? []).find((c) => c.isActive)?.id ?? "";
const [endDate, setEndDate] = useState("");
const [type, setType] = useState("");
const [reason, setReason] = useState("");
const [formError, setFormError] = useState<string | null>(null);
// 默认选择第一个活跃班级 const items = leaveResult?.items ?? [];
useEffect(() => { const total = leaveResult?.total ?? 0;
if (!classId && classes) { const currentPage = leaveResult?.page ?? page;
const firstActive = classes.find((c) => c.isActive); const currentPageSize = leaveResult?.pageSize ?? pageSize;
if (firstActive) setClassId(firstActive.id);
}
}, [classes, classId]);
const hasActiveClass = (classes ?? []).some((c) => c.isActive); const handlePageChange = useCallback((next: number): void => {
setPage(Math.max(1, next));
const handleFormSubmit = (): void => { }, []);
setFormError(null);
if (!classId) {
setFormError("请选择班级");
return;
}
if (!startDate) {
setFormError("请选择开始日期");
return;
}
if (!endDate) {
setFormError("请选择结束日期");
return;
}
if (!type) {
setFormError("请选择请假类型");
return;
}
if (!reason.trim()) {
setFormError("请填写请假原因");
return;
}
if (endDate < startDate) {
setFormError("结束日期不能早于开始日期");
return;
}
const input: LeaveRequestInput = {
classId,
startDate,
endDate,
reason: reason.trim(),
type,
};
const handleFormSubmitted = useCallback((): void => {
// 提交成功后回到第 1 页并刷新
setPage(1);
void (async (): Promise<void> => { void (async (): Promise<void> => {
try { try {
await submitLeave(input);
notify.success(t("submitSuccess"));
// 重置表单(保留班级选择),刷新历史
setStartDate("");
setEndDate("");
setType("");
setReason("");
await refetch(); await refetch();
} catch { } catch (err) {
notify.error(t("submitError")); notify.error(tCommon("error.loadFailed", { message: String(err) }));
} }
})(); })();
}; }, [refetch, tCommon]);
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -135,234 +98,31 @@ export function StudentLeaveClient(): React.ReactElement {
loadingNode={<DetailPageSkeleton />} loadingNode={<DetailPageSkeleton />}
errorNode={errorNode} errorNode={errorNode}
> >
<Button asChild variant="ghost" size="sm" className="-ml-2 gap-2">
<Link href="/shell/student">
<ArrowLeft className="size-4" />
{t("backToDashboard")}
</Link>
</Button>
<div className="space-y-6"> <div className="space-y-6">
<DetailSection title={t("sectionForm")}> <DetailSection title={t("sectionForm")}>
{!hasActiveClass ? ( <LeaveRequestForm
<p className="text-sm text-muted-foreground"> defaultClassId={defaultClassId}
{t("noActiveClass")} onSubmitted={handleFormSubmitted}
</p>
) : null}
<FormField label={t("fieldClassId")} required>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
disabled={!hasActiveClass}
>
<option value="">{t("fieldClassId")}</option>
{(classes ?? []).map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</FormField>
<FormField label={t("fieldStartDate")} required>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/> />
</FormField>
<FormField label={t("fieldEndDate")} required>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
<FormField label={t("fieldType")} required>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">{t("fieldType")}</option>
<option value="personal">{t("typePersonal")}</option>
<option value="sick">{t("typeSick")}</option>
<option value="other">{t("typeOther")}</option>
</select>
</FormField>
<FormField label={t("fieldReason")} required>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</FormField>
{formError ? (
<p className="text-sm text-destructive">{formError}</p>
) : null}
<Button
onClick={handleFormSubmit}
disabled={submitting || !hasActiveClass}
>
{t("submit")}
</Button>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</DetailSection> </DetailSection>
<DetailSection title={t("sectionHistory")}> <DetailSection title={t("sectionHistory")}>
<LeaveHistoryTable items={leaveItems ?? []} /> <LeaveRequestList
items={items}
total={total}
page={currentPage}
pageSize={currentPageSize}
loading={loading}
onPageChange={handlePageChange}
/>
</DetailSection> </DetailSection>
</div> </div>
</DetailPageShell> </DetailPageShell>
); );
} }
/**
* 表单字段容器label + children
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}
/**
* 请假记录历史表格(含空态)。
*/
function LeaveHistoryTable({
items,
}: {
items: StudentLeaveItem[];
}): React.ReactElement {
const t = useTranslations("studentDomain.leave");
if (items.length === 0) {
return <EmptyState icon={CalendarClock} title={t("emptyTitle")} />;
}
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colStartDate")}</th>
<th className="p-3 text-left font-medium">{t("colEndDate")}</th>
<th className="p-3 text-left font-medium">{t("colType")}</th>
<th className="p-3 text-left font-medium">{t("colReason")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatDate(item.startDate)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatDate(item.endDate)}
</td>
<td className="p-3">{formatLeaveType(item.type, t)}</td>
<td className="p-3 text-xs text-muted-foreground">
{item.reason}
</td>
<td className="p-3">
<LeaveStatusBadge status={item.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 请假状态徽章pending 琥珀 / approved 翡翠 / rejected 危险色)。
*/
function LeaveStatusBadge({ status }: { status: string }): React.ReactElement {
const t = useTranslations("studentDomain.leave");
const label = leaveStatusToLabel(status, t);
const cls = leaveStatusToBadgeClass(status);
return (
<span
className={cn(
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium",
cls,
)}
>
{label}
</span>
);
}
/** 将请假状态枚举值映射为 i18n 标签。未知状态回退为原始值。 */
function leaveStatusToLabel(
status: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (status) {
case "pending":
return t("statusPending");
case "approved":
return t("statusApproved");
case "rejected":
return t("statusRejected");
default:
return status;
}
}
/** 根据请假状态返回 Tailwind 徽章类名。 */
function leaveStatusToBadgeClass(status: string): string {
switch (status) {
case "pending":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "approved":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "rejected":
return "bg-destructive/10 text-destructive";
default:
return "bg-muted text-muted-foreground";
}
}
/** 将请假类型枚举值映射为 i18n 标签。未知类型回退为原始值。 */
function formatLeaveType(
type: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (type) {
case "personal":
return t("typePersonal");
case "sick":
return t("typeSick");
case "other":
return t("typeOther");
default:
return type;
}
}
/** 格式化 ISO 日期字符串为本地化展示。 */
function formatDate(isoDate: string): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN");
}

View File

@@ -6,6 +6,7 @@
* *
* 数据契约: * 数据契约:
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - subjectOptions ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
@@ -15,9 +16,19 @@
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect, useMemo, useState } from "react";
import { useStudentLessonPlans, type StudentLessonPlan } from "@/lib/api"; import {
useStudentLessonPlans,
useSubjectOptions,
type StudentLessonPlan,
} from "@/lib/api";
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 { import {
ListPageShell, ListPageShell,
@@ -34,6 +45,10 @@ export function StudentLessonPlansListClient(): React.ReactElement {
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useStudentLessonPlans(); const { data, loading, error } = useStudentLessonPlans();
const { data: subjectOptions } = useSubjectOptions();
// 学科筛选状态subject id
const [subjectFilter, setSubjectFilter] = useState<string>("");
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -41,7 +56,16 @@ export function StudentLessonPlansListClient(): React.ReactElement {
} }
}, [error, tCommon]); }, [error, tCommon]);
const items = data ?? []; // 仅展示已发布教案(不在前端展示草稿),并按学科筛选
const visibleItems = useMemo(() => {
const published = (data ?? []).filter((p) => p.status === "published");
if (!subjectFilter) return published;
const subjectName = (subjectOptions ?? []).find(
(s) => s.id === subjectFilter,
)?.name;
if (!subjectName) return published;
return published.filter((p) => p.subject === subjectName);
}, [data, subjectFilter, subjectOptions]);
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -68,71 +92,88 @@ export function StudentLessonPlansListClient(): React.ReactElement {
title={t("title")} title={t("title")}
description={t("description")} description={t("description")}
icon={<FileText className="size-6" />} icon={<FileText className="size-6" />}
filters={
<select
value={subjectFilter}
onChange={(e) => setSubjectFilter(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm md:w-60"
aria-label={t("subjectFilter")}
>
<option value="">{t("allSubjects")}</option>
{(subjectOptions ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
}
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
empty={items.length === 0 && !loading} empty={visibleItems.length === 0 && !loading}
emptyNode={emptyNode} emptyNode={emptyNode}
errorNode={errorNode} errorNode={errorNode}
pagination={ pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: items.length })}</span> <span>{t("total", { count: visibleItems.length })}</span>
</div> </div>
} }
> >
<StudentLessonPlansTable items={items} /> <StudentLessonPlansGrid items={visibleItems} />
</ListPageShell> </ListPageShell>
); );
} }
/** /**
* 学生教案列表表格(纯展示组件,对齐 §8.2 排版规范)。 * 学生教案卡片网格md:grid-cols-2 lg:grid-cols-3复刻 CICD lesson-plan-list)。
*/ */
function StudentLessonPlansTable({ function StudentLessonPlansGrid({
items, items,
}: { }: {
items: StudentLessonPlan[]; items: StudentLessonPlan[];
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.lessonPlans.list"); const t = useTranslations("studentDomain.lessonPlans.list");
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((plan) => ( {items.map((plan) => (
<tr key={plan.id} className="hover:bg-muted/30"> <Card
<td className="p-3"> key={plan.id}
<Link className="group flex flex-col transition-all hover:-translate-y-1 hover:shadow-md"
href={`/shell/student/lesson-plans/${plan.id}/view`}
className="font-medium hover:underline"
> >
<CardHeader className="pb-3">
<CardTitle className="line-clamp-2 text-base font-semibold">
{plan.title} {plan.title}
</Link> </CardTitle>
</td> </CardHeader>
<td className="p-3 text-muted-foreground">{plan.subject}</td> <CardContent className="flex flex-1 flex-col gap-3">
<td className="p-3 text-muted-foreground">{plan.grade}</td> <dl className="grid grid-cols-2 gap-2 text-sm">
<td className="p-3 font-mono text-xs text-muted-foreground"> <div>
<dt className="text-xs text-muted-foreground">
{t("colSubject")}
</dt>
<dd className="font-medium">{plan.subject}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">
{t("colGrade")}
</dt>
<dd className="font-medium">{plan.grade}</dd>
</div>
</dl>
<p className="font-mono text-xs text-muted-foreground">
{plan.updatedAt} {plan.updatedAt}
</td> </p>
<td className="p-3 text-right"> <div className="mt-auto pt-2">
<Link <Link
href={`/shell/student/lesson-plans/${plan.id}/view`} href={`/shell/student/lesson-plans/${plan.id}/view`}
className="text-xs text-muted-foreground hover:text-foreground" className="inline-flex items-center text-xs font-medium text-primary hover:underline"
> >
{t("viewDetail")} {t("viewDetail")}
</Link> </Link>
</td> </div>
</tr> </CardContent>
</Card>
))} ))}
</tbody>
</table>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
// @contract-pendingstudentLessonPlanView schema 未实现,全 MSW 兜底 // @contract-pendingstudentLessonPlanView schema 未实现,全 MSW 兜底
// @contract-pending scope-check范围校验待 core-edu 服务接入后启用
/** /**
* 学生教案只读查看页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2 * 学生教案只读查看页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
* *
@@ -13,12 +14,16 @@
* - errorerrorNode 局部降级 * - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点 * - notFounddata 为 null 时显示空态节点
* *
* 范围校验(@contract-pending scope-check
* - 当前 portal-shell 使用 MSW 兜底,无真实 ctx.dataScope
* - TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect, useMemo, type ReactNode } from "react";
import { import {
useStudentLessonPlanView, useStudentLessonPlanView,
@@ -35,6 +40,8 @@ import { notify } from "@/shared/lib/notify";
/** /**
* 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。 * 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。
*
* 全高度布局h-[calc(100vh-4rem)]+ 内部 overflow-y-auto提升阅读体验。
*/ */
export function StudentLessonPlanViewClient(): React.ReactElement { export function StudentLessonPlanViewClient(): React.ReactElement {
const t = useTranslations("studentDomain.lessonPlans.view"); const t = useTranslations("studentDomain.lessonPlans.view");
@@ -72,8 +79,10 @@ export function StudentLessonPlanViewClient(): React.ReactElement {
) : undefined; ) : undefined;
return ( return (
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
<DetailPageShell <DetailPageShell
title={data?.title ?? t("title")} title={data?.title ?? t("title")}
description={data ? buildSubtitle(data, t) : undefined}
icon={<FileText className="size-6" />} icon={<FileText className="size-6" />}
backHref="/shell/student/lesson-plans" backHref="/shell/student/lesson-plans"
loading={loading} loading={loading}
@@ -83,11 +92,36 @@ export function StudentLessonPlanViewClient(): React.ReactElement {
> >
{data ? <LessonPlanViewBody plan={data} /> : null} {data ? <LessonPlanViewBody plan={data} /> : null}
</DetailPageShell> </DetailPageShell>
</div>
); );
} }
/**
* 构造副标题字符串:优先教材/章节,缺失时退化为学科/年级。
*/
function buildSubtitle(
plan: LessonPlanViewData,
t: ReturnType<typeof useTranslations>,
): string {
const parts: string[] = [];
if (plan.textbookTitle) {
parts.push(`${t("fieldTextbook")}: ${plan.textbookTitle}`);
}
if (plan.chapterTitle) {
parts.push(`${t("fieldChapter")}: ${plan.chapterTitle}`);
}
if (parts.length === 0) {
parts.push(`${t("fieldSubject")}: ${plan.subject}`);
parts.push(`${t("fieldGrade")}: ${plan.grade}`);
}
return parts.join(" · ");
}
/** /**
* 教案查看主体(基本信息 + 教案内容)。 * 教案查看主体(基本信息 + 教案内容)。
*
* 已发布状态检查status !== "published" 时显示友好提示并阻止渲染内容。
* 范围校验占位:@contract-pending scope-check见文件头 TODO
*/ */
function LessonPlanViewBody({ function LessonPlanViewBody({
plan, plan,
@@ -95,12 +129,327 @@ function LessonPlanViewBody({
plan: LessonPlanViewData; plan: LessonPlanViewData;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.lessonPlans.view"); const t = useTranslations("studentDomain.lessonPlans.view");
// TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围
// @contract-pending scope-check
// 已发布状态检查:未发布时阻止渲染内容
if (plan.status && plan.status !== "published") {
return ( return (
<DetailSection title={t("sectionContent")}> <div className="rounded-xl border border-border bg-muted/50 p-6 text-center">
<DetailField label={t("title")} value={plan.title} /> <p className="text-sm text-muted-foreground">{t("notPublished")}</p>
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
{plan.content}
</div> </div>
);
}
return (
<div className="flex flex-col gap-6">
<DetailSection title={t("sectionBasic")}>
<DetailField label={t("fieldSubject")} value={plan.subject} />
<DetailField label={t("fieldGrade")} value={plan.grade} />
{plan.textbookTitle ? (
<DetailField label={t("fieldTextbook")} value={plan.textbookTitle} />
) : null}
{plan.chapterTitle ? (
<DetailField label={t("fieldChapter")} value={plan.chapterTitle} />
) : null}
</DetailSection> </DetailSection>
<DetailSection title={t("sectionContent")}>
<RichLessonContent content={plan.content} />
</DetailSection>
</div>
);
}
/* ------------------------------------------------------------------ *
* 富文档渲染(自研简易解析,不引入新依赖)
*
* 支持两种内容形式:
* 1. 结构化 JSON{ sections: [{ title, content, items }] }
* 2. 简易 markdown 文本:标题(#/##/###、中文序号"一、二、…")、
* 有序列表1.)、无序列表(-/*)、段落、行内加粗(**text**
* ------------------------------------------------------------------ */
/** 块级节点类型 */
type BlockNode =
| {
readonly type: "heading";
readonly level: 1 | 2 | 3;
readonly text: string;
}
| { readonly type: "paragraph"; readonly text: string }
| {
readonly type: "list";
readonly ordered: boolean;
readonly items: readonly string[];
};
/** 中文序号正则:一、二、三、... 十、十一、… */
const CN_HEADING_RE = /^[一二三四五六七八九十]+、\s*(.+)$/;
/** 阿拉伯数字有序列表项1. xxx */
const OL_RE = /^\d+\.\s+(.+)$/;
/** 无序列表项:- xxx 或 * xxx */
const UL_RE = /^[-*]\s+(.+)$/;
/** Markdown 标题:# xxx / ## xxx / ### xxx */
const MD_HEADING_RE = /^(#{1,3})\s+(.+)$/;
/** 行内加粗:**text** */
const BOLD_RE = /\*\*([^*]+)\*\*/;
/** 类型守卫:判断 value 是否为字符串 */
function isString(value: unknown): value is string {
return typeof value === "string";
}
/** 类型守卫:判断 value 是否为结构化 sections 文档 */
function isStructuredDoc(value: unknown): value is {
sections: ReadonlyArray<{
title?: unknown;
content?: unknown;
items?: unknown;
}>;
} {
if (typeof value !== "object" || value === null) return false;
if (!("sections" in value)) return false;
// 从 unknown 转换为具体类型(允许的 as 场景)
const obj = value as { sections: unknown };
return Array.isArray(obj.sections);
}
/** 将数字钳制到 1-3 区间(用于标题层级) */
function clampLevel(n: number): 1 | 2 | 3 {
if (n >= 3) return 3;
if (n === 2) return 2;
return 1;
}
/**
* 解析教案内容字符串为块级节点数组。
*
* 优先尝试 JSON 结构化解析;失败则按简易 markdown 文本解析。
*/
function parseLessonContent(content: string): readonly BlockNode[] {
if (content.trim().startsWith("{")) {
try {
const parsed: unknown = JSON.parse(content);
if (isStructuredDoc(parsed)) {
return parseStructuredSections(parsed.sections);
}
} catch {
// JSON 解析失败,回退到文本解析
}
}
return parseMarkdownLike(content);
}
/** 解析结构化 sections 为块级节点 */
function parseStructuredSections(
sections: ReadonlyArray<{
title?: unknown;
content?: unknown;
items?: unknown;
}>,
): readonly BlockNode[] {
const nodes: BlockNode[] = [];
for (const section of sections) {
if (isString(section.title) && section.title.length > 0) {
nodes.push({ type: "heading", level: 2, text: section.title });
}
if (isString(section.content) && section.content.length > 0) {
nodes.push({ type: "paragraph", text: section.content });
}
if (Array.isArray(section.items)) {
const items = section.items.filter(isString);
if (items.length > 0) {
nodes.push({ type: "list", ordered: false, items });
}
}
}
return nodes;
}
/** 解析简易 markdown 文本为块级节点 */
function parseMarkdownLike(content: string): readonly BlockNode[] {
const lines = content.split(/\r?\n/);
const nodes: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? "";
const trimmed = line.trim();
// 空行:跳过(段落分隔由节点边界自然形成)
if (trimmed === "") {
i += 1;
continue;
}
// Markdown 标题
const mdMatch = MD_HEADING_RE.exec(trimmed);
if (mdMatch) {
const hashes = mdMatch[1] ?? "";
const text = mdMatch[2] ?? "";
nodes.push({ type: "heading", level: clampLevel(hashes.length), text });
i += 1;
continue;
}
// 中文序号标题(一、二、三、)
const cnMatch = CN_HEADING_RE.exec(trimmed);
if (cnMatch) {
const text = cnMatch[1] ?? "";
nodes.push({ type: "heading", level: 2, text });
i += 1;
continue;
}
// 有序列表
if (OL_RE.test(trimmed)) {
const items: string[] = [];
while (i < lines.length) {
const cur = (lines[i] ?? "").trim();
const m = OL_RE.exec(cur);
if (!m) break;
items.push(m[1] ?? "");
i += 1;
}
nodes.push({ type: "list", ordered: true, items });
continue;
}
// 无序列表
if (UL_RE.test(trimmed)) {
const items: string[] = [];
while (i < lines.length) {
const cur = (lines[i] ?? "").trim();
const m = UL_RE.exec(cur);
if (!m) break;
items.push(m[1] ?? "");
i += 1;
}
nodes.push({ type: "list", ordered: false, items });
continue;
}
// 段落
nodes.push({ type: "paragraph", text: trimmed });
i += 1;
}
return nodes;
}
/** 渲染行内文本(处理 **bold** 加粗) */
function renderInline(text: string): ReactNode[] {
const parts: ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
const m = BOLD_RE.exec(remaining);
if (!m) {
parts.push(remaining);
break;
}
if (m.index > 0) {
parts.push(remaining.slice(0, m.index));
}
parts.push(
<strong key={`b-${key}`} className="font-semibold">
{m[1]}
</strong>,
);
key += 1;
remaining = remaining.slice(m.index + m[0].length);
}
return parts;
}
/**
* 富文档渲染组件。
*
* 将教案内容字符串解析为块级节点并渲染标题h2/h3/h4、段落p
* 有序/无序列表ol/ul + li。纯文本场景保留 whitespace-pre-wrap。
*/
function RichLessonContent({
content,
}: {
content: string;
}): React.ReactElement {
const nodes = useMemo(() => parseLessonContent(content), [content]);
// 纯文本退化:仅单个段落且无结构时,保留 whitespace-pre-wrap
if (nodes.length === 1 && nodes[0]?.type === "paragraph") {
return (
<div className="whitespace-pre-wrap rounded-md border bg-card p-4 text-sm leading-7 text-foreground">
{renderInline(nodes[0].text)}
</div>
);
}
return (
<div className="space-y-3 rounded-md border bg-card p-4">
{nodes.map((node, idx) => {
const key = `block-${idx}`;
if (node.type === "heading") {
if (node.level === 1) {
return (
<h2
key={key}
className="mt-4 text-xl font-bold text-foreground first:mt-0"
>
{renderInline(node.text)}
</h2>
);
}
if (node.level === 2) {
return (
<h3
key={key}
className="mt-3 text-lg font-semibold text-foreground first:mt-0"
>
{renderInline(node.text)}
</h3>
);
}
return (
<h4
key={key}
className="mt-2 text-base font-semibold text-foreground first:mt-0"
>
{renderInline(node.text)}
</h4>
);
}
if (node.type === "paragraph") {
return (
<p
key={key}
className="whitespace-pre-wrap leading-7 text-foreground"
>
{renderInline(node.text)}
</p>
);
}
// list
if (node.ordered) {
return (
<ol key={key} className="ml-5 list-decimal space-y-1">
{node.items.map((item, i) => (
<li key={`li-${i}`} className="leading-7 text-foreground">
{renderInline(item)}
</li>
))}
</ol>
);
}
return (
<ul key={key} className="ml-5 list-disc space-y-1">
{node.items.map((item, i) => (
<li key={`li-${i}`} className="leading-7 text-foreground">
{renderInline(item)}
</li>
))}
</ul>
);
})}
</div>
); );
} }

View File

@@ -1,42 +1,181 @@
"use client"; "use client";
// @contract-pendingstudentPractice schema 未实现,全 MSW 兜底 // @contract-pending: studentPractice / studentKnowledgePoints / startPracticeSession schema -> MSW
/** import {
* 学生自适应练习列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3 Award,
* CheckCircle2,
* 数据契约: Clock,
* - studentPractice ❌ schema 无此字段 → MSW 兜底(@contract-pending Dumbbell,
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-practice Filter,
* Target,
* 三态规范§11.3 DoD TrendingUp,
* - loadingListPageSkeleton XCircle,
* - errorerrorNode 局部降级 } from "lucide-react";
* - emptydata 为空时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
*/
import { Dumbbell } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useStudentPractice } from "@/lib/api"; import {
useStudentKnowledgePoints,
useStartPracticeSession,
useStudentPractice,
} from "@/lib/api";
import type {
StudentKnowledgePoint,
KnowledgePointDifficulty,
StudentSubject,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { StatCard } from "@/shared/components/ui/stat-card";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { notify } from "@/shared/lib/notify";
import { cn } from "@/shared/lib/utils";
/** const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const;
* 练习列表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/ type SubjectFilter = StudentSubject | "all";
const SUBJECT_OPTIONS: ReadonlyArray<SubjectFilter> = [
"all",
"math",
"chinese",
"english",
"physics",
"chemistry",
];
type SubjectLabelKey =
| "subjectMath"
| "subjectChinese"
| "subjectEnglish"
| "subjectPhysics"
| "subjectChemistry";
const SUBJECT_LABEL_KEYS: Record<StudentSubject, SubjectLabelKey> = {
math: "subjectMath",
chinese: "subjectChinese",
english: "subjectEnglish",
physics: "subjectPhysics",
chemistry: "subjectChemistry",
};
type DifficultyLabelKey =
"difficultyEasy" | "difficultyMedium" | "difficultyHard";
const DIFFICULTY_LABEL_KEYS: Record<
KnowledgePointDifficulty,
DifficultyLabelKey
> = {
easy: "difficultyEasy",
medium: "difficultyMedium",
hard: "difficultyHard",
};
const DIFFICULTY_ORDER: Record<KnowledgePointDifficulty, number> = {
easy: 1,
medium: 2,
hard: 3,
};
const DIFFICULTY_BADGE_CLASSES: Record<KnowledgePointDifficulty, string> = {
easy: "bg-emerald-500/10 text-emerald-600",
medium: "bg-amber-500/10 text-amber-600",
hard: "bg-rose-500/10 text-rose-600",
};
const SUBJECT_FILTER_VALUES = new Set<string>([
"all",
"math",
"chinese",
"english",
"physics",
"chemistry",
]);
function isSubjectFilter(val: string): val is SubjectFilter {
return SUBJECT_FILTER_VALUES.has(val);
}
export function StudentPracticeListClient(): React.ReactElement { export function StudentPracticeListClient(): React.ReactElement {
const t = useTranslations("studentDomain.practice.list"); const t = useTranslations("studentDomain.practice.list");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter();
// @contract-pendingMSW 兜底
const { data, loading, error } = useStudentPractice(); const { data, loading, error } = useStudentPractice();
const { run: startSession, loading: creating } = useStartPracticeSession();
const {
data: knowledgePoints,
loading: kpLoading,
error: kpError,
} = useStudentKnowledgePoints();
const [selectedKpIds, setSelectedKpIds] = useState<string[]>([]);
const [questionCount, setQuestionCount] = useState<number>(10);
const [subjectFilter, setSubjectFilter] = useState<SubjectFilter>("all");
const filteredKps = useMemo<StudentKnowledgePoint[]>(() => {
if (!knowledgePoints) return [];
if (subjectFilter === "all") return knowledgePoints;
return knowledgePoints.filter((kp) => kp.subject === subjectFilter);
}, [knowledgePoints, subjectFilter]);
const groupedKps = useMemo<
Map<StudentSubject, StudentKnowledgePoint[]>
>(() => {
const groups = new Map<StudentSubject, StudentKnowledgePoint[]>();
for (const kp of filteredKps) {
const arr = groups.get(kp.subject) ?? [];
arr.push(kp);
groups.set(kp.subject, arr);
}
for (const arr of groups.values()) {
arr.sort((a, b) => {
const d =
DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty];
return d !== 0 ? d : a.name.localeCompare(b.name);
});
}
return groups;
}, [filteredKps]);
const handleChangeSubject = (next: SubjectFilter): void => {
setSubjectFilter(next);
setSelectedKpIds([]);
};
const toggleKp = (kpId: string): void => {
setSelectedKpIds((prev) =>
prev.includes(kpId) ? prev.filter((id) => id !== kpId) : [...prev, kpId],
);
};
const handleStart = (): void => {
if (selectedKpIds.length === 0) {
notify.error(t("starterSelectKnowledgePoint"));
return;
}
void (async (): Promise<void> => {
try {
const sessionId = await startSession(selectedKpIds);
notify.success(t("starterCreated"));
router.push(`/shell/student/practice/${sessionId}`);
} catch (err) {
notify.error(t("starterCreateFailed", { message: String(err) }));
}
})();
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -46,7 +185,13 @@ export function StudentPracticeListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
const emptyNode = <EmptyState icon={Dumbbell} title={t("emptyTitle")} />; const emptyNode =
!loading && !error && !data ? (
<EmptyState icon={Dumbbell} title={t("emptyTitle")} />
) : undefined;
const stats = data?.stats;
const history = data?.history ?? [];
return ( return (
<ListPageShell <ListPageShell
@@ -55,119 +200,334 @@ export function StudentPracticeListClient(): React.ReactElement {
icon={<Dumbbell className="size-6" />} icon={<Dumbbell className="size-6" />}
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
empty={!loading && !error && !data}
emptyNode={emptyNode} emptyNode={emptyNode}
errorNode={errorNode} errorNode={errorNode}
pagination={ pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span>{t("mswNotice")}</span> <span>{t("mswNotice")}</span>
</div> </div>
} }
> >
{data ? <PracticeBody data={data} /> : null} {data ? (
</ListPageShell> <div className="space-y-6">
); <StatsGrid columns={4}>
}
/**
* 练习内容区(统计卡片 + 历史会话列表)。
*/
function PracticeBody({
data,
}: {
data: NonNullable<ReturnType<typeof useStudentPractice>["data"]>;
}): React.ReactElement {
const t = useTranslations("studentDomain.practice.list");
return (
<>
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("sectionStats")}</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard <StatCard
title={t("fieldTotalSessions")} title={t("fieldTotalSessions")}
value={String(data.stats.totalSessions)} value={String(stats?.totalSessions ?? 0)}
icon={Target}
valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title={t("fieldCompletedSessions")} title={t("fieldCompletedSessions")}
value={String(data.stats.completedSessions)} value={String(stats?.completedSessions ?? 0)}
icon={CheckCircle2}
valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title={t("fieldTotalQuestions")} title={t("fieldTotalQuestions")}
value={String(data.stats.totalQuestions)} value={String(stats?.totalQuestions ?? 0)}
icon={Award}
valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title={t("fieldCorrectRate")} title={t("fieldCorrectRate")}
value={formatRate(data.stats.correctRate)} value={formatRate(stats?.correctRate ?? 0)}
icon={TrendingUp}
valueClassName="tabular-nums"
/> />
</div> </StatsGrid>
</section> <div className="grid gap-6 lg:grid-cols-3">
<PracticeStarter
<section className="space-y-3"> filteredKps={filteredKps}
groupedKps={groupedKps}
selectedKpIds={selectedKpIds}
questionCount={questionCount}
subjectFilter={subjectFilter}
kpLoading={kpLoading}
kpError={kpError}
onChangeSubject={handleChangeSubject}
onToggleKp={toggleKp}
onChangeQuestionCount={setQuestionCount}
onStart={handleStart}
creating={creating}
/>
<div className="space-y-4 lg:col-span-2">
<h2 className="text-lg font-semibold">{t("sectionHistory")}</h2> <h2 className="text-lg font-semibold">{t("sectionHistory")}</h2>
<PracticeHistoryTable history={data.history} /> <PracticeHistory sessions={history} />
</section> </div>
</> </div>
</div>
) : null}
</ListPageShell>
); );
} }
interface PracticeStarterProps {
filteredKps: StudentKnowledgePoint[];
groupedKps: Map<StudentSubject, StudentKnowledgePoint[]>;
selectedKpIds: string[];
questionCount: number;
subjectFilter: SubjectFilter;
kpLoading: boolean;
kpError: unknown;
onChangeSubject: (next: SubjectFilter) => void;
onToggleKp: (kpId: string) => void;
onChangeQuestionCount: (count: number) => void;
onStart: () => void;
creating: boolean;
}
/** function PracticeStarter({
* 历史会话表格(纯展示组件,对齐 §8.2 排版规范)。 filteredKps,
*/ groupedKps,
function PracticeHistoryTable({ selectedKpIds,
history, questionCount,
subjectFilter,
kpLoading,
kpError,
onChangeSubject,
onToggleKp,
onChangeQuestionCount,
onStart,
creating,
}: PracticeStarterProps): React.ReactElement {
const t = useTranslations("studentDomain.practice.list");
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Target className="size-4 text-primary" aria-hidden />
{t("sectionStarter")}
</CardTitle>
<CardDescription>{t("starterDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label
htmlFor="practice-subject-filter"
className="flex items-center gap-1 text-sm font-medium"
>
<Filter className="size-3" aria-hidden />
{t("subjectFilterLabel")}
</label>
<select
id="practice-subject-filter"
value={subjectFilter}
onChange={(e) => {
if (isSubjectFilter(e.target.value)) {
onChangeSubject(e.target.value);
}
}}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{SUBJECT_OPTIONS.map((s) => (
<option key={s} value={s}>
{s === "all" ? t("subjectFilterAll") : t(SUBJECT_LABEL_KEYS[s])}
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor="practice-kp-group" className="text-sm font-medium">
{t("knowledgePointSelector")}
</label>
{selectedKpIds.length > 0 ? (
<span className="text-xs font-medium text-primary">
{t("selectedCount", { count: selectedKpIds.length })}
</span>
) : null}
</div>
{kpLoading ? (
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground">
{t("knowledgePointLoading")}
</div>
) : kpError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-center text-sm text-destructive">
{t("knowledgePointLoadFailed", { message: String(kpError) })}
</div>
) : filteredKps.length === 0 ? (
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground">
{t("knowledgePointEmpty")}
</div>
) : (
<div
id="practice-kp-group"
role="group"
aria-label={t("knowledgePointSelector")}
className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-2"
>
{Array.from(groupedKps.entries()).map(([subject, kps]) => (
<div key={subject} className="space-y-1">
<div className="sticky top-0 bg-card px-1 py-0.5 text-xs font-semibold text-muted-foreground">
{t(SUBJECT_LABEL_KEYS[subject])}
</div>
{kps.map((kp) => (
<KnowledgePointToggle
key={kp.id}
kp={kp}
selected={selectedKpIds.includes(kp.id)}
onToggle={onToggleKp}
/>
))}
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<label
htmlFor="practice-question-count"
className="text-sm font-medium"
>
{t("starterQuestionCount")}
</label>
<select
id="practice-question-count"
value={questionCount}
onChange={(e) => {
const num = Number(e.target.value);
if (Number.isInteger(num)) {
onChangeQuestionCount(num);
}
}}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{QUESTION_COUNT_OPTIONS.map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
</div>
<Button
type="button"
onClick={onStart}
disabled={creating || selectedKpIds.length === 0}
className="w-full"
aria-label={creating ? t("starterCreating") : t("startPractice")}
>
{creating ? t("starterCreating") : t("startPractice")}
</Button>
</CardContent>
</Card>
);
}
function KnowledgePointToggle({
kp,
selected,
onToggle,
}: { }: {
history: NonNullable< kp: StudentKnowledgePoint;
selected: boolean;
onToggle: (kpId: string) => void;
}): React.ReactElement {
const t = useTranslations("studentDomain.practice.list");
return (
<button
type="button"
onClick={() => onToggle(kp.id)}
aria-pressed={selected}
className={cn(
"flex w-full flex-col gap-1 rounded-md p-2 text-left transition-colors hover:bg-muted/50",
selected
? "bg-primary/10 ring-1 ring-primary"
: "bg-card ring-1 ring-border",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{kp.name}</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
DIFFICULTY_BADGE_CLASSES[kp.difficulty],
)}
>
{t(DIFFICULTY_LABEL_KEYS[kp.difficulty])}
</span>
</div>
{kp.description ? (
<span className="text-xs text-muted-foreground">{kp.description}</span>
) : null}
<span className="text-xs text-muted-foreground/70">{kp.grade}</span>
</button>
);
}
function PracticeHistory({
sessions,
}: {
sessions: NonNullable<
ReturnType<typeof useStudentPractice>["data"] ReturnType<typeof useStudentPractice>["data"]
>["history"]; >["history"];
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.practice.list"); const t = useTranslations("studentDomain.practice.list");
if (history.length === 0) { if (sessions.length === 0) {
return <p className="text-sm text-muted-foreground">{t("emptyTitle")}</p>; return (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
<Target className="mx-auto mb-2 size-8 opacity-50" />
{t("emptyTitle")}
</CardContent>
</Card>
);
} }
return ( return (
<div className="overflow-x-auto rounded-xl border"> <div className="space-y-3">
<table className="w-full text-sm"> {sessions.map((session) => {
<thead className="border-b bg-muted/30"> const correctCount = Math.round(
<tr> session.totalQuestions * session.correctRate,
<th className="p-3 text-left font-medium">{t("colSessionDate")}</th> );
<th className="p-3 text-left font-medium"> const wrongCount = session.totalQuestions - correctCount;
{t("colTotalQuestions")} return (
</th>
<th className="p-3 text-left font-medium">{t("colCorrectRate")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{history.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatDate(item.date)}
</td>
<td className="p-3">{String(item.totalQuestions)}</td>
<td className="p-3">{formatRate(item.correctRate)}</td>
<td className="p-3 text-right">
<Link <Link
href={`/shell/student/practice/${item.id}`} key={session.id}
className="text-xs text-muted-foreground hover:text-foreground" href={`/shell/student/practice/${session.id}`}
className="block"
> >
{t("viewDetail")} <Card className="transition-colors hover:bg-muted/30">
<CardContent className="p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatDate(session.date)}
</span>
<span className="flex items-center gap-1">
<CheckCircle2 className="size-3 text-emerald-500" />
{correctCount}
</span>
<span className="flex items-center gap-1">
<XCircle className="size-3 text-rose-500" />
{wrongCount}
</span>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold tabular-nums">
{formatRate(session.correctRate)}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{session.totalQuestions}
</div>
</div>
</div>
</CardContent>
</Card>
</Link> </Link>
</td> );
</tr> })}
))}
</tbody>
</table>
</div> </div>
); );
} }
/** 格式化 0-1 的比率为百分比字符串。 */
function formatRate(rate: number): string { function formatRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--"; if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(1)}%`; return `${(rate * 100).toFixed(1)}%`;
} }
/** 格式化 ISO 日期字符串为本地化展示。 */
function formatDate(isoDate: string): string { function formatDate(isoDate: string): string {
if (!isoDate) return "--"; if (!isoDate) return "--";
const d = new Date(isoDate); const d = new Date(isoDate);

View File

@@ -8,13 +8,15 @@
* - studentClasses用于班级筛选器选项❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentClasses用于班级筛选器选项❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-schedule * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#student-schedule
* *
* URL 状态:?classId=xxx * URL 状态:?classId=xxx(默认 all
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
* 复刻 CICDstudent-schedule-view.tsx周卡片 + 今日高亮 + 按 startTime 排序)
*/ */
import { Calendar } from "lucide-react"; import { Calendar, CalendarX, UserX } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -24,23 +26,38 @@ import {
useStudentSchedule, useStudentSchedule,
type StudentScheduleItem, type StudentScheduleItem,
} from "@/lib/api"; } from "@/lib/api";
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 { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { cn } from "@/shared/lib/utils";
/** 周一到周日weekday 1-7 → 周一到周日)。 */ /** 周一到周日weekday 1-7 → monday 到 sundayi18n 键名映射。 */
const WEEKDAY_LABELS = [ const WEEKDAY_KEYS = [
"周一", "monday",
"周二", "tuesday",
"周三", "wednesday",
"周四", "thursday",
"周五", "friday",
"周六", "saturday",
"周日", "sunday",
] as const; ] as const;
/** JS getDay() (0=Sun..6=Sat) → 业务 weekday (1=Mon..7=Sun)。 */
function getTodayWeekday(): number {
const map = [7, 1, 2, 3, 4, 5, 6];
const day = new Date().getDay();
if (day < 0 || day > 6) return 1;
return map[day]!;
}
/** /**
* 课表客户端主体。需由 server page 包裹在 <Suspense> 中 * 课表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。 * useSearchParams 要求 Suspense 边界Next.js 15 强制)。
@@ -57,29 +74,20 @@ export function StudentScheduleListClient(): React.ReactElement {
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data: scheduleItems, loading, error } = useStudentSchedule(); const { data: scheduleItems, loading, error } = useStudentSchedule();
// 班级筛选器选项studentClasses 提供班级列表 // 班级筛选器选项studentClasses 提供班级列表
// 注意StudentScheduleItem 当前无 classId 字段,筛选仅做 URL 状态记录,
// 实际过滤待后端契约补齐(@contract-pending
const { data: classes } = useStudentClasses(); const { data: classes } = useStudentClasses();
const groupedByWeekday = useMemo(() => {
const items = scheduleItems ?? []; const items = scheduleItems ?? [];
const groups: Array<{ weekday: number; items: StudentScheduleItem[] }> = [];
for (let wd = 1; wd <= 7; wd++) {
const dayItems = items
.filter((item) => item.weekday === wd)
.sort((a, b) => a.period - b.period);
if (dayItems.length > 0) {
groups.push({ weekday: wd, items: dayItems });
}
}
return groups;
}, [scheduleItems]);
const totalItems = (scheduleItems ?? []).length; // 按班级过滤classId 字段已补齐,过滤实际生效)
const filteredItems = useMemo(() => {
if (!classId || classId === "all") return items;
return items.filter((item) => item.classId === classId);
}, [items, classId]);
const totalItems = filteredItems.length;
const updateQuery = (key: string, value: string): void => { const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
if (value) { if (value && value !== "all") {
params.set(key, value); params.set(key, value);
} else { } else {
params.delete(key); params.delete(key);
@@ -98,7 +106,22 @@ export function StudentScheduleListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
const emptyNode = <EmptyState icon={Calendar} title={t("emptyTitle")} />; // 学生身份校验scheduleItems 为 undefined 且非 loading/error 时视为无学生身份
const noStudent = !loading && !error && scheduleItems === undefined;
const emptyNode = noStudent ? (
<EmptyState
icon={UserX}
title={t("noStudentTitle")}
description={t("noStudentDesc")}
/>
) : (
<EmptyState
icon={CalendarX}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
);
return ( return (
<ListPageShell <ListPageShell
@@ -107,12 +130,12 @@ export function StudentScheduleListClient(): React.ReactElement {
icon={<Calendar className="size-6" />} icon={<Calendar className="size-6" />}
filters={ filters={
<select <select
value={classId} value={classId || "all"}
onChange={(e) => updateQuery("classId", e.target.value)} onChange={(e) => updateQuery("classId", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm md:w-60"
aria-label={t("classFilter")} aria-label={t("classFilter")}
> >
<option value="">{t("allClasses")}</option> <option value="all">{t("allClasses")}</option>
{(classes ?? []).map((cls) => ( {(classes ?? []).map((cls) => (
<option key={cls.id} value={cls.id}> <option key={cls.id} value={cls.id}>
{cls.name} {cls.name}
@@ -125,71 +148,114 @@ export function StudentScheduleListClient(): React.ReactElement {
empty={totalItems === 0 && !loading} empty={totalItems === 0 && !loading}
emptyNode={emptyNode} emptyNode={emptyNode}
errorNode={errorNode} errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("mswNotice")}</span>
</div>
}
> >
<div className="space-y-6"> <StudentScheduleView items={filteredItems} />
{groupedByWeekday.map((group) => (
<WeekdayScheduleGroup
key={group.weekday}
weekday={group.weekday}
items={group.items}
/>
))}
</div>
</ListPageShell> </ListPageShell>
); );
} }
/** /**
* 按星期分组的课表展示 * 课表周视图(复刻 CICD StudentScheduleView
*
* 每个星期一张卡片lg:grid-cols-2今日卡片高亮
* 卡片内按 startTime 排序展示节次。
*/ */
function WeekdayScheduleGroup({ function StudentScheduleView({
weekday,
items, items,
}: { }: {
weekday: number;
items: StudentScheduleItem[]; items: StudentScheduleItem[];
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.schedule"); const t = useTranslations("studentDomain.schedule");
const label = WEEKDAY_LABELS[weekday - 1] ?? `Day ${weekday}`; const todayKey = getTodayWeekday();
const itemsByDay = useMemo(() => {
const map = new Map<number, StudentScheduleItem[]>();
for (const item of items) {
const list = map.get(item.weekday) ?? [];
list.push(item);
map.set(item.weekday, list);
}
for (const list of map.values()) {
list.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
return map;
}, [items]);
return ( return (
<section className="rounded-xl border bg-card"> <div className="grid gap-4 lg:grid-cols-2">
<header className="border-b p-4"> {WEEKDAY_KEYS.map((dayKey, idx) => {
<h3 className="text-base font-semibold">{label}</h3> const weekday = idx + 1;
</header> const dayItems = itemsByDay.get(weekday) ?? [];
<div className="overflow-x-auto"> const isToday = weekday === todayKey;
<table className="w-full text-sm"> const label = t(`weekdays.${dayKey}`);
<thead className="border-b bg-muted/30"> return (
<tr> <Card
<th className="p-3 text-left font-medium">{t("colPeriod")}</th> key={weekday}
<th className="p-3 text-left font-medium">{t("colSubject")}</th> className={cn(
<th className="p-3 text-left font-medium">{t("colTeacher")}</th> isToday && "border-primary ring-1 ring-primary/30 shadow-sm",
<th className="p-3 text-left font-medium">{t("colClassroom")}</th> )}
<th className="p-3 text-left font-medium">{t("colTime")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, idx) => (
<tr
key={`${item.weekday}-${item.period}-${idx}`}
className="hover:bg-muted/30"
> >
<td className="p-3 font-mono text-xs">{item.period}</td> <CardHeader className="pb-3">
<td className="p-3 font-medium">{item.subject}</td> <CardTitle className="flex items-center gap-2 text-sm font-medium">
<td className="p-3 text-muted-foreground">{item.teacher}</td> <span>{label}</span>
<td className="p-3 text-muted-foreground">{item.classroom}</td> {isToday ? (
<td className="p-3 font-mono text-xs text-muted-foreground"> <span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-primary-foreground">
{item.time} {t("today")}
</td> </span>
</tr> ) : null}
</CardTitle>
</CardHeader>
<CardContent>
{dayItems.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("noClasses")}
</p>
) : (
<ul className="space-y-3">
{dayItems.map((item, i) => (
<ScheduleListItem
key={`${item.weekday}-${item.period}-${i}`}
item={item}
/>
))} ))}
</tbody> </ul>
</table> )}
</CardContent>
</Card>
);
})}
</div> </div>
</section> );
}
/** 单条课表展示(节次 + 科目 + 教师 + 教室 + 时间 + 课程跳转)。 */
function ScheduleListItem({
item,
}: {
item: StudentScheduleItem;
}): React.ReactElement {
const t = useTranslations("studentDomain.schedule");
return (
<li className="flex items-center justify-between rounded-md border p-3">
<div className="flex items-center gap-3">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{item.period}
</span>
<div>
<Link
href={`/shell/student/courses/${item.classId}`}
className="font-medium hover:underline"
>
{item.subject}
</Link>
<p className="text-xs text-muted-foreground">
{item.teacher}
{item.classroom ? ` · ${item.classroom}` : ""}
</p>
</div>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{item.time || t("colTime")}
</div>
</li>
); );
} }

View File

@@ -1,11 +1,14 @@
"use client"; "use client";
// @contract-pendingstudentTextbookChapters schema 未实现,全 MSW 兜底 // @contract-pendingstudentTextbookChapters schema 未实现,全 MSW 兜底
// @contract-pending student-grade-filter理想做法是后端 BFF 在 studentTextbookChapters
// resolver 中按学生年级校验;当前在前端用 useStudentClasses().grade 兜底校验。
/** /**
* 学生教材章节阅读器 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2 * 学生教材章节阅读器 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
* *
* 数据契约: * 数据契约:
* - studentTextbookChapters(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentTextbookChapters(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 学生年级:来自 studentClasses首个活跃班级的 grade前端二次校验
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* *
* 三态规范§11.3 DoD * 三态规范§11.3 DoD
@@ -15,14 +18,22 @@
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { BookOpen } from "lucide-react"; import {
AlertTriangle,
BookOpen,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
useStudentClasses,
useStudentTextbookChapters, useStudentTextbookChapters,
type StudentTextbookChapter, type StudentTextbookChapter,
type StudentTextbookChapterContent,
type StudentTextbookChapterSection,
} from "@/lib/api"; } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { import {
@@ -42,16 +53,38 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const textbookId = params?.id ?? ""; const textbookId = params?.id ?? "";
// 学生年级:从首个活跃班级获取(@contract-pending student-grade-filter
const {
data: classes,
loading: loadingClasses,
error: classesError,
} = useStudentClasses();
const studentGrade = classes?.find((c) => c.isActive)?.grade ?? "";
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useStudentTextbookChapters(textbookId); const {
data,
loading: loadingChapters,
error,
} = useStudentTextbookChapters(textbookId);
const [selectedId, setSelectedId] = useState<string>(""); const [selectedId, setSelectedId] = useState<string>("");
const loading = loadingClasses || loadingChapters;
useEffect(() => { useEffect(() => {
if (error) { if (error) {
notify.error(tCommon("error.loadFailed", { message: String(error) })); notify.error(tCommon("error.loadFailed", { message: String(error) }));
} }
}, [error, tCommon]); }, [error, tCommon]);
useEffect(() => {
if (classesError) {
notify.error(
tCommon("error.loadFailed", { message: String(classesError) }),
);
}
}, [classesError, tCommon]);
// 选中首个章节 // 选中首个章节
useEffect(() => { useEffect(() => {
if (data && data.chapters.length > 0 && !selectedId) { if (data && data.chapters.length > 0 && !selectedId) {
@@ -62,6 +95,12 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
} }
}, [data, selectedId]); }, [data, selectedId]);
// 年级不匹配警告前端兜底校验理想BFF resolver 校验)
const gradeMismatch =
!loadingClasses && !classesError && studentGrade && data
? data.grade !== studentGrade
: false;
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -95,6 +134,9 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
{data ? ( {data ? (
<ChaptersReader <ChaptersReader
chapters={data.chapters} chapters={data.chapters}
subject={data.subject}
grade={data.grade}
gradeMismatch={gradeMismatch}
selectedId={selectedId} selectedId={selectedId}
onSelect={setSelectedId} onSelect={setSelectedId}
/> />
@@ -104,19 +146,58 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
} }
/** /**
* 章节阅读器(左侧章节列表 + 右侧阅读区)。 * 章节阅读器(顶栏 + 左侧章节列表 + 右侧阅读区)。
*/ */
function ChaptersReader({ function ChaptersReader({
chapters, chapters,
subject,
grade,
gradeMismatch,
selectedId, selectedId,
onSelect, onSelect,
}: { }: {
chapters: StudentTextbookChapter[]; chapters: StudentTextbookChapter[];
subject: string;
grade: string;
gradeMismatch: boolean;
selectedId: string; selectedId: string;
onSelect: (id: string) => void; onSelect: (id: string) => void;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("studentDomain.textbooks.chapters"); const t = useTranslations("studentDomain.textbooks.chapters");
const selected = chapters.find((ch) => ch.id === selectedId) ?? chapters[0];
const selectedIndex = useMemo(
() => chapters.findIndex((ch) => ch.id === selectedId),
[chapters, selectedId],
);
const selected = selectedIndex >= 0 ? chapters[selectedIndex] : chapters[0];
const safeIndex = selectedIndex >= 0 ? selectedIndex : 0;
const hasPrev = safeIndex > 0;
const hasNext = safeIndex < chapters.length - 1;
const handlePrev = (): void => {
if (!hasPrev) return;
const prev = chapters[safeIndex - 1];
if (prev) onSelect(prev.id);
};
const handleNext = (): void => {
if (!hasNext) return;
const next = chapters[safeIndex + 1];
if (next) onSelect(next.id);
};
if (chapters.length === 0) {
return (
<div className="flex flex-col gap-4">
<ReaderHeader
subject={subject}
grade={grade}
gradeMismatch={gradeMismatch}
/>
<EmptyState icon={BookOpen} title={t("emptyChapters")} />
</div>
);
}
if (!selected) { if (!selected) {
return ( return (
@@ -132,11 +213,14 @@ function ChaptersReader({
} }
return ( return (
<div className="flex flex-col gap-4">
<ReaderHeader
subject={subject}
grade={grade}
gradeMismatch={gradeMismatch}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
<DetailSection title={t("sectionChapters")}> <DetailSection title={t("sectionChapters")}>
{chapters.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
) : (
<ul className="space-y-1"> <ul className="space-y-1">
{chapters.map((ch, idx) => ( {chapters.map((ch, idx) => (
<li key={ch.id}> <li key={ch.id}>
@@ -144,29 +228,160 @@ function ChaptersReader({
type="button" type="button"
onClick={() => onSelect(ch.id)} onClick={() => onSelect(ch.id)}
className={cn( className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors", "flex w-full items-start gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
ch.id === selected.id ch.id === selected.id
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "hover:bg-muted", : "hover:bg-muted",
)} )}
> >
<span className="font-mono text-xs opacity-70"> <span className="font-mono text-xs opacity-70">
{idx + 1}. {t("chapterLabel", { n: idx + 1 })}
</span> </span>
<span className="truncate">{ch.title}</span> <span className="truncate">{ch.title}</span>
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
)}
</DetailSection> </DetailSection>
<DetailSection title={t("sectionContent")}> <DetailSection
<h3 className="text-lg font-semibold">{selected.title}</h3> title={t("sectionContent")}
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm"> actions={
{selected.content} <div className="flex items-center gap-2">
<button
type="button"
onClick={handlePrev}
disabled={!hasPrev}
className="inline-flex h-8 items-center gap-1 rounded-md border border-input bg-background px-2 text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="size-3" />
{t("prevChapter")}
</button>
<button
type="button"
onClick={handleNext}
disabled={!hasNext}
className="inline-flex h-8 items-center gap-1 rounded-md border border-input bg-background px-2 text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
{t("nextChapter")}
<ChevronRight className="size-3" />
</button>
</div> </div>
}
>
<h3 className="text-lg font-semibold">{selected.title}</h3>
<ChapterContent chapter={selected} />
</DetailSection> </DetailSection>
</div> </div>
</div>
);
}
/**
* 阅读器顶栏:学科/年级徽章 + 年级不匹配警告。
*/
function ReaderHeader({
subject,
grade,
gradeMismatch,
}: {
subject: string;
grade: string;
gradeMismatch: boolean;
}): React.ReactElement {
const t = useTranslations("studentDomain.textbooks.chapters");
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex h-6 items-center rounded-full bg-muted px-2 text-xs font-medium">
{t("subjectBadge")}: {subject}
</span>
<span className="inline-flex h-6 items-center rounded-full bg-muted px-2 text-xs font-medium">
{t("gradeBadge")}: {grade}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{t("mswNotice")}
</span>
</div>
{gradeMismatch ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{t("gradeMismatchWarning", { grade })}</span>
</div>
) : null}
</div>
);
}
/**
* 章节内容渲染:优先渲染 structuredContent.sections兜底渲染 content。
*/
function ChapterContent({
chapter,
}: {
chapter: StudentTextbookChapter;
}): React.ReactElement {
if (
chapter.structuredContent &&
chapter.structuredContent.sections.length > 0
) {
return <StructuredContent content={chapter.structuredContent} />;
}
return (
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
{chapter.content}
</div>
);
}
/**
* 结构化分节内容渲染。
*/
function StructuredContent({
content,
}: {
content: StudentTextbookChapterContent;
}): React.ReactElement {
return (
<div className="space-y-4 rounded-md border bg-card p-4">
{content.sections.map((section, idx) => (
<SectionBlock key={idx} section={section} />
))}
</div>
);
}
/**
* 单个分节块渲染heading + paragraphs + list
*/
function SectionBlock({
section,
}: {
section: StudentTextbookChapterSection;
}): React.ReactElement {
return (
<div className="space-y-2">
{section.heading ? (
<h4 className="text-sm font-semibold text-foreground">
{section.heading}
</h4>
) : null}
{section.paragraphs && section.paragraphs.length > 0 ? (
<div className="space-y-1.5">
{section.paragraphs.map((p, idx) => (
<p key={idx} className="text-sm leading-relaxed text-foreground">
{p}
</p>
))}
</div>
) : null}
{section.list && section.list.length > 0 ? (
<ul className="ml-4 list-disc space-y-1 text-sm text-muted-foreground">
{section.list.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
) : null}
</div>
); );
} }

View File

@@ -1,26 +1,36 @@
"use client"; "use client";
// @contract-pendingstudentTextbooks schema 未实现,全 MSW 兜底 // @contract-pendingstudentTextbooks schema 未实现,全 MSW 兜底
// @contract-pending student-grade-filter理想做法是后端 BFF 在 studentTextbooks
// resolver 中按学生年级过滤;当前在前端用 useStudentClasses().grade 兜底过滤。
/** /**
* 学生教材列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2 * 学生教材列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
* *
* 数据契约: * 数据契约:
* - studentTextbooks(q, subject, grade) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - studentTextbooks(q, subject, grade) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 学生年级:来自 studentClasses首个活跃班级的 grade前端二次过滤
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* *
* URL 状态:?q=&subject=&grade= * URL 状态:?q=&subject=&grade=
* *
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
* - 区分有/无筛选的空态
* - 学生年级未设置时显示提示
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/ */
import { BookOpen } from "lucide-react"; import { BookOpen, RotateCcw, SearchX } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useTransition } from "react"; import { useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useStudentTextbooks, type StudentTextbook } from "@/lib/api"; import {
useStudentClasses,
useStudentTextbooks,
type StudentTextbook,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { import {
@@ -28,15 +38,22 @@ import {
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
const SUBJECT_OPTIONS = ["语文", "数学", "英语", "物理", "化学", "生物"]; const SUBJECT_OPTION_KEYS = [
const GRADE_OPTIONS = [ "chinese",
"一年级", "math",
"二年级", "english",
"三年级", "physics",
"七年级", "chemistry",
"八年级", "biology",
"九年级", ] as const;
]; const GRADE_OPTION_KEYS = [
"grade1",
"grade2",
"grade3",
"grade7",
"grade8",
"grade9",
] as const;
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -53,14 +70,36 @@ export function StudentTextbooksListClient(): React.ReactElement {
const subject = searchParams.get("subject") ?? ""; const subject = searchParams.get("subject") ?? "";
const grade = searchParams.get("grade") ?? ""; const grade = searchParams.get("grade") ?? "";
// 学生年级:从首个活跃班级获取(@contract-pending student-grade-filter
// 学生信息 hook 未单独提供 grade借用 useStudentClasses 兜底。
const {
data: classes,
loading: loadingClasses,
error: classesError,
} = useStudentClasses();
const studentGrade = classes?.find((c) => c.isActive)?.grade ?? "";
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useStudentTextbooks({ const {
data,
loading: loadingTextbooks,
error,
} = useStudentTextbooks({
q: q || undefined, q: q || undefined,
subject: subject || undefined, subject: subject || undefined,
grade: grade || undefined, grade: grade || undefined,
}); });
const items = data ?? []; // 前端按学生年级强制过滤理想BFF resolver 过滤)
const rawItems = data ?? [];
const items = studentGrade
? rawItems.filter((tb) => tb.grade === studentGrade)
: rawItems;
const loading = loadingClasses || loadingTextbooks;
const hasActiveFilters = Boolean(q || subject || grade);
// 年级未设置:班级已加载、无错误、且无活跃班级含 grade
const gradeNotSet = !loadingClasses && !classesError && !studentGrade;
const updateQuery = (key: string, value: string): void => { const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -74,6 +113,12 @@ export function StudentTextbooksListClient(): React.ReactElement {
}); });
}; };
const clearAllFilters = (): void => {
startTransition(() => {
router.push("/shell/student/textbooks");
});
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -83,7 +128,34 @@ export function StudentTextbooksListClient(): React.ReactElement {
</div> </div>
) : undefined; ) : undefined;
const emptyNode = ( // 年级未设置:单独提示,避免显示不属于学生年级的教材
if (gradeNotSet) {
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BookOpen className="size-6" />}
>
<EmptyState
icon={BookOpen}
title={t("gradeNotSetTitle")}
description={t("gradeNotSetDescription")}
/>
</ListPageShell>
);
}
const emptyNode = hasActiveFilters ? (
<EmptyState
icon={SearchX}
title={t("emptyFilteredTitle")}
description={t("emptyFilteredDescription")}
action={{
label: t("clearFilters"),
onClick: clearAllFilters,
}}
/>
) : (
<EmptyState <EmptyState
icon={BookOpen} icon={BookOpen}
title={t("emptyTitle")} title={t("emptyTitle")}
@@ -94,13 +166,9 @@ export function StudentTextbooksListClient(): React.ReactElement {
/> />
); );
return ( const filtersNode = (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BookOpen className="size-6" />}
filters={
<> <>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<FilterSearchInput <FilterSearchInput
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
value={q} value={q}
@@ -113,9 +181,9 @@ export function StudentTextbooksListClient(): React.ReactElement {
aria-label={t("subjectFilter")} aria-label={t("subjectFilter")}
> >
<option value="">{t("allSubjects")}</option> <option value="">{t("allSubjects")}</option>
{SUBJECT_OPTIONS.map((s) => ( {SUBJECT_OPTION_KEYS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{s} {t(`subjects.${s}`)}
</option> </option>
))} ))}
</select> </select>
@@ -126,14 +194,33 @@ export function StudentTextbooksListClient(): React.ReactElement {
aria-label={t("gradeFilter")} aria-label={t("gradeFilter")}
> >
<option value="">{t("allGrades")}</option> <option value="">{t("allGrades")}</option>
{GRADE_OPTIONS.map((g) => ( {GRADE_OPTION_KEYS.map((g) => (
<option key={g} value={g}> <option key={g} value={g}>
{g} {t(`grades.${g}`)}
</option> </option>
))} ))}
</select> </select>
</div>
{hasActiveFilters ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={clearAllFilters}
>
<RotateCcw className="size-4" />
{t("clearFilters")}
</Button>
) : null}
</> </>
} );
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<BookOpen className="size-6" />}
filters={filtersNode}
loading={loading} loading={loading}
loadingNode={<ListPageSkeleton rows={5} />} loadingNode={<ListPageSkeleton rows={5} />}
empty={items.length === 0 && !loading} empty={items.length === 0 && !loading}

View File

@@ -5,11 +5,11 @@
* *
* 数据契约(@contract-pending 全 MSW * 数据契约(@contract-pending 全 MSW
* - aiAssistSessions(teacherId) ❌ → MSW 兜底 * - aiAssistSessions(teacherId) ❌ → MSW 兜底
* - sendAiMessage(input) mutation ❌ → MSW 兜底 * - 聊天流式响应 → /api/ai/chat/streamAiChatPanel 内部 useAiChatStreamSSE
* *
* 布局WorkbenchPageShell * 布局WorkbenchPageShell
* - left会话列表 * - left会话列表MSW 兜底)
* - center聊天区域(消息历史 + 输入框 * - centerAI 聊天面板AiChatPanel widget 变体SSE 流式逐 token 渲染
* - right上下文信息统计 * - right上下文信息统计
* *
* 三态规范§11.3 DoD * 三态规范§11.3 DoD
@@ -19,12 +19,11 @@
* *
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
*/ */
import { Bot, Send } from "lucide-react"; import { Bot } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useAiAssistSessions, useSendAiMessage } from "@/lib/api"; import { useAiAssistSessions } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
WorkbenchPageShell, WorkbenchPageShell,
@@ -35,13 +34,7 @@ import {
formatAiDate, formatAiDate,
totalMessageCount, totalMessageCount,
} from "@/features/teacher/ai/transformations"; } from "@/features/teacher/ai/transformations";
import { AiChatPanel } from "@/features/teacher/ai/components/ai-chat-panel";
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
createdAt: string;
}
export function AiAssistClient(): React.ReactElement { export function AiAssistClient(): React.ReactElement {
const t = useTranslations("ai"); const t = useTranslations("ai");
@@ -49,13 +42,10 @@ export function AiAssistClient(): React.ReactElement {
const teacherId = "dev-teacher-001"; const teacherId = "dev-teacher-001";
const { data: sessions, loading, error } = useAiAssistSessions(teacherId); const { data: sessions, loading, error } = useAiAssistSessions(teacherId);
const sendMessage = useSendAiMessage();
const [selectedSessionId, setSelectedSessionId] = useState<string | null>( const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null, null,
); );
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const sessionList = useMemo(() => { const sessionList = useMemo(() => {
const items = sessions ?? []; const items = sessions ?? [];
@@ -67,34 +57,6 @@ export function AiAssistClient(): React.ReactElement {
const totalCount = totalMessageCount(sessions ?? []); const totalCount = totalMessageCount(sessions ?? []);
const handleSend = async (): Promise<void> => {
const content = input.trim();
if (!content) return;
setInput("");
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
try {
const result = await sendMessage.run({
sessionId: selectedSessionId ?? undefined,
message: content,
});
const aiMsg: ChatMessage = {
id: result.messageId,
role: "assistant",
content: result.reply,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, aiMsg]);
} catch (err) {
notify.error(t("assist.sendFailed", { message: String(err) }));
}
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -152,59 +114,15 @@ export function AiAssistClient(): React.ReactElement {
} }
center={ center={
<WorkbenchPanel title={t("assist.chatTitle")}> <WorkbenchPanel title={t("assist.chatTitle")}>
<div className="flex h-full flex-col"> <AiChatPanel
<div className="flex-1 space-y-4 overflow-y-auto pr-2"> variant="widget"
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
<div>
<Bot className="mx-auto size-12 opacity-50" />
<p className="mt-3 text-sm">{t("assist.emptyChat")}</p>
<p className="mt-1 text-xs">{t("assist.emptyChatHint")}</p>
</div>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-prose rounded-lg px-4 py-2 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
))
)}
</div>
<div className="mt-4 flex gap-2 border-t pt-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder={t("assist.inputPlaceholder")} placeholder={t("assist.inputPlaceholder")}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm" suggestedPrompts={[
aria-label={t("assist.inputPlaceholder")} t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.teacher.2"),
]}
/> />
<Button
onClick={() => void handleSend()}
disabled={!input.trim() || sendMessage.loading}
>
<Send className="size-4" />
{t("assist.send")}
</Button>
</div>
</div>
</WorkbenchPanel> </WorkbenchPanel>
} }
right={ right={

View File

@@ -27,12 +27,7 @@ import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { import { AttendanceRecordList } from "./components";
attendanceStatusToBadgeClass,
formatAttendanceDate,
formatAttendanceDay,
formatAttendanceStatus,
} from "@/features/teacher/attendance/transformations";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -157,85 +152,7 @@ export function AttendanceListClient(): React.ReactElement {
</div> </div>
} }
> >
<AttendanceTable items={filteredItems} /> <AttendanceRecordList records={filteredItems} />
</ListPageShell> </ListPageShell>
); );
} }
/**
* 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function AttendanceTable({
items,
}: {
items: AttendanceRecord[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("list.colStudentName")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colClassName")}
</th>
<th className="p-3 text-left font-medium">{t("list.colDate")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">{t("list.colRemark")}</th>
<th className="p-3 text-left font-medium">
{t("list.colRecordedBy")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((r) => (
<tr key={r.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{r.studentName}</td>
<td className="p-3 text-muted-foreground">{r.className}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAttendanceDay(r.date)}
</td>
<td className="p-3">
<AttendanceStatusBadge status={r.status} />
</td>
<td className="p-3 text-xs text-muted-foreground">
{r.remark ?? "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{r.recordedBy}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAttendanceDate(r.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 考勤状态徽章(按状态色阶展示)。
*/
function AttendanceStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatAttendanceStatus(status);
const cls = attendanceStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -38,6 +38,12 @@ import {
progressToColorClass, progressToColorClass,
} from "@/features/teacher/course-plans/transformations"; } from "@/features/teacher/course-plans/transformations";
// P2 迁移:周计划编辑器 / 周计划表格行 / 模板选择器(供详情页配套使用)
export { CoursePlanItemEditor } from "./components/course-plan-item-editor";
export type { CoursePlanItemPayload } from "./components/course-plan-item-editor";
export { SortableWeekRow } from "./components/sortable-week-row";
export { TemplatePickerDialog } from "./components/template-picker-dialog";
/** /**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/ */

View File

@@ -31,6 +31,9 @@ import {
formatCoursePlanStatus, formatCoursePlanStatus,
} from "@/features/teacher/course-plans/transformations"; } from "@/features/teacher/course-plans/transformations";
// P2 迁移:模板选择器(供列表页"从模板创建"入口配套使用)
export { TemplatePickerDialog } from "./components/template-picker-dialog";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。 * useSearchParams 要求 Suspense 边界Next.js 15 强制)。

View File

@@ -37,6 +37,52 @@ import {
formatStudentCount, formatStudentCount,
masteryToColorClass, masteryToColorClass,
} from "@/features/teacher/diagnostic/transformations"; } from "@/features/teacher/diagnostic/transformations";
import { ClassDiagnosticView } from "./components/class-diagnostic-view";
import { DiagnosticServiceProvider } from "./services/diagnostic-service-context";
import type { ClassMasterySummary } from "./components/types";
// P2 迁移:诊断模块组件(雷达图、报告列表、置信度工具)
export { MasteryRadarChart } from "./components/mastery-radar-chart";
export { ReportList } from "./components/report-list";
export {
getConfidenceLevel,
confidenceBadgeVariant,
type ConfidenceLevel,
} from "./components/confidence-utils";
export type {
DiagnosticReportWithDetails,
MasteryRadarPoint,
ReportStatus,
ReportType,
} from "./components/types";
/**
* 将诊断报告详情映射为 ClassMasterySummary 供 ClassDiagnosticView 消费。
* 弱项知识点映射为 knowledgePointStatsmastery 0-1 → 0-100
* 需关注学生暂缺数据返回空数组,待后端契约补齐后填充。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页
*/
function buildClassMasterySummary(
detail: NonNullable<ReturnType<typeof useDiagnosticReport>["data"]>,
classId: string,
): ClassMasterySummary {
return {
classId,
className: detail.class_name,
studentCount: detail.student_count,
averageMastery: detail.avg_score,
knowledgePointStats: detail.weak_points.map((wp) => ({
knowledgePointId: wp.knowledge_point_id,
knowledgePointName: wp.title,
averageMastery: wp.mastery * 100,
masteredCount: 0,
notMasteredCount: 0,
totalStudents: detail.student_count,
})),
studentsNeedingAttention: [],
};
}
/** /**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -95,6 +141,13 @@ export function DiagnosticClassDetailClient(): React.ReactElement {
{data ? ( {data ? (
<RecommendationsSection recommendations={data.recommendations} /> <RecommendationsSection recommendations={data.recommendations} />
) : null} ) : null}
{data ? (
<DiagnosticServiceProvider>
<ClassDiagnosticView
summary={buildClassMasterySummary(data, classId)}
/>
</DiagnosticServiceProvider>
) : null}
</DetailPageShell> </DetailPageShell>
); );
} }

View File

@@ -42,6 +42,104 @@ import {
masteryLevelToBadgeClass, masteryLevelToBadgeClass,
} from "@/features/teacher/diagnostic/student-diagnostic-transformations"; } from "@/features/teacher/diagnostic/student-diagnostic-transformations";
import { formatDiagnosticDate } from "@/features/teacher/diagnostic/transformations"; import { formatDiagnosticDate } from "@/features/teacher/diagnostic/transformations";
import { StudentDiagnosticView } from "./components/student-diagnostic-view";
import type {
DiagnosticReportWithDetails,
MasteryWithKnowledgePoint,
ReportStatus,
ReportType,
StudentMasterySummary,
} from "./components/types";
// P2 迁移:学生诊断详情页可引用雷达图组件(共享导出)
export { MasteryRadarChart } from "./components/mastery-radar-chart";
export type { MasteryRadarPoint } from "./components/types";
/**
* 将 portal-shell 学生诊断报告状态映射为 DiagnosticReportWithDetails 的小写状态。
* portal-shell schema 使用 UPPERCASEPUBLISHED/DRAFT/ARCHIVED
*/
function mapReportStatus(status: string): ReportStatus {
const lower = status.toLowerCase();
if (lower === "published") return "published";
if (lower === "archived") return "archived";
return "draft";
}
/**
* 将 portal-shell 报告类型WEEKLY/MONTHLY/EXAM/UNIT映射为
* DiagnosticReportWithDetails.reportType 联合类型。
* 学生诊断报告默认归为 individual。
*/
function mapReportType(_reportType: string): ReportType {
return "individual";
}
/**
* 将学生诊断详情映射为 StudentMasterySummary 供 StudentDiagnosticView 消费。
* knowledge_points.score 作为 masteryLevel>=80 入 strengths<80 入 weaknesses。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页
*/
function buildStudentMasterySummary(
data: NonNullable<ReturnType<typeof useStudentDiagnostic>["data"]>,
): StudentMasterySummary {
const allMastery: MasteryWithKnowledgePoint[] = data.knowledge_points.map(
(kp) => ({
knowledgePointId: kp.kp_id,
knowledgePointName: kp.kp_title,
knowledgePointDescription: null,
masteryLevel: kp.score,
totalQuestions: 0,
correctQuestions: 0,
lastAssessedAt: null,
}),
);
return {
studentId: data.student_id,
studentName: data.student_name,
averageMastery:
allMastery.length === 0
? 0
: allMastery.reduce((sum, m) => sum + m.masteryLevel, 0) /
allMastery.length,
totalKnowledgePoints: allMastery.length,
strengths: allMastery.filter((m) => m.masteryLevel >= 80),
weaknesses: allMastery.filter((m) => m.masteryLevel < 80),
allMastery,
};
}
/**
* 将 portal-shell StudentDiagnosticReport 映射为 DiagnosticReportWithDetails[]。
* strengths/weaknesses/recommendations 在 schema 中暂缺,统一返回 null。
*/
function buildStudentReports(
reports: NonNullable<
ReturnType<typeof useStudentDiagnostic>["data"]
>["reports"],
studentName: string,
): DiagnosticReportWithDetails[] {
return reports.map((r) => ({
id: r.report_id,
studentId: null,
classId: null,
gradeId: null,
generatedBy: null,
reportType: mapReportType(r.report_type),
period: null,
summary: r.summary,
strengths: null,
weaknesses: null,
recommendations: null,
overallScore: null,
status: mapReportStatus(r.status),
createdAt: r.generated_at,
updatedAt: r.generated_at,
studentName,
generatedByName: null,
}));
}
/** /**
* 学生诊断详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * 学生诊断详情客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -101,6 +199,13 @@ export function StudentDiagnosticClient(): React.ReactElement {
<KnowledgePointsSection knowledgePoints={data.knowledge_points} /> <KnowledgePointsSection knowledgePoints={data.knowledge_points} />
) : null} ) : null}
{data ? <StudentReportsSection reports={data.reports} /> : null} {data ? <StudentReportsSection reports={data.reports} /> : null}
{data ? (
<StudentDiagnosticView
summary={buildStudentMasterySummary(data)}
reports={buildStudentReports(data.reports, data.student_name)}
role="teacher"
/>
) : null}
</DetailPageShell> </DetailPageShell>
); );
} }

View File

@@ -20,6 +20,7 @@ import { useTransition, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCreateElective, type CreateElectiveInput } from "@/lib/api"; import { useCreateElective, type CreateElectiveInput } from "@/lib/api";
import { ElectivePageLayout } from "./components";
import { FormPageShell } from "@/shared/components/page-templates"; import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
@@ -113,6 +114,7 @@ function ElectiveFormInner({
}; };
return ( return (
<ElectivePageLayout header={null} className="p-0 space-y-0">
<FormPageShell <FormPageShell
title={t("create.title")} title={t("create.title")}
description={t("create.description")} description={t("create.description")}
@@ -123,7 +125,9 @@ function ElectiveFormInner({
submitLabel={t("create.submit")} submitLabel={t("create.submit")}
cancelLabel={tCommon("button.cancel")} cancelLabel={tCommon("button.cancel")}
errorSummary={ errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined error ? (
<p className="text-sm text-destructive">{error}</p>
) : undefined
} }
> >
<FormField label={t("create.nameLabel")} required> <FormField label={t("create.nameLabel")} required>
@@ -208,6 +212,7 @@ function ElectiveFormInner({
{t("create.contractPending")} {t("create.contractPending")}
</p> </p>
</FormPageShell> </FormPageShell>
</ElectivePageLayout>
); );
} }

View File

@@ -25,6 +25,7 @@ import {
useUpdateElective, useUpdateElective,
type UpdateElectiveInput, type UpdateElectiveInput,
} from "@/lib/api"; } from "@/lib/api";
import { ElectivePageLayout } from "./components";
import { import {
FormPageShell, FormPageShell,
FormPageSkeleton, FormPageSkeleton,
@@ -141,6 +142,7 @@ export function ElectiveEditClient(): React.ReactElement {
) : undefined; ) : undefined;
return ( return (
<ElectivePageLayout header={null} className="p-0 space-y-0">
<FormPageShell <FormPageShell
title={t("edit.title")} title={t("edit.title")}
description={t("edit.description")} description={t("edit.description")}
@@ -247,6 +249,7 @@ export function ElectiveEditClient(): React.ReactElement {
</> </>
) : null} ) : null}
</FormPageShell> </FormPageShell>
</ElectivePageLayout>
); );
} }

View File

@@ -34,6 +34,7 @@ import {
formatElectiveStatus, formatElectiveStatus,
formatEnrollmentCount, formatEnrollmentCount,
} from "@/features/teacher/elective/transformations"; } from "@/features/teacher/elective/transformations";
import { ElectivePageLayout } from "./components";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -130,7 +131,9 @@ export function ElectiveListClient(): React.ReactElement {
</div> </div>
} }
> >
<ElectivePageLayout header={null} className="space-y-4 p-0">
<ElectiveTable items={filteredItems} /> <ElectiveTable items={filteredItems} />
</ElectivePageLayout>
</ListPageShell> </ListPageShell>
); );
} }

View File

@@ -6,6 +6,68 @@
*/ */
import type { Elective, ElectiveListItem, ElectiveStatus } from "@/lib/api"; import type { Elective, ElectiveListItem, ElectiveStatus } from "@/lib/api";
import type { ElectiveCourseStatus, ElectiveCourseWithDetails } from "./types";
/**
* 将 portal-shell 的 ElectiveStatus大写枚举映射为迁移组件的 ElectiveCourseStatus小写枚举
* ARCHIVED → cancelled语义最接近不再活跃
*/
export function mapElectiveStatus(
status: ElectiveStatus,
): ElectiveCourseStatus {
switch (status) {
case "DRAFT":
return "draft";
case "OPEN":
return "open";
case "CLOSED":
return "closed";
case "ARCHIVED":
return "cancelled";
default:
return "draft";
}
}
/**
* 将 portal-shell 的 Elective 适配为迁移组件使用的 ElectiveCourseWithDetails。
*
* 字段映射说明:
* - subject → subjectId / subjectNameportal-shell 用字符串,迁移组件用 ID + 名称)
* - gradeLevel → gradeId / gradeName同上
* - Elective 不含的字段classroom / schedule / dates / selectionMode / credit使用默认值或 null
*
* 关联ARCHITECTURE.md §5.3 / §9.1
*/
export function electiveToCourseWithDetails(
item: Elective,
): ElectiveCourseWithDetails {
return {
id: item.id,
name: item.name,
subjectId: item.subject,
teacherId: item.teacherId,
gradeId: item.gradeLevel,
description: item.description || null,
capacity: item.capacity,
enrolledCount: item.enrolledCount,
classroom: null,
schedule: null,
startDate: null,
endDate: null,
selectionStartAt: null,
selectionEndAt: null,
dropDeadline: null,
status: mapElectiveStatus(item.status),
selectionMode: "fcfs",
credit: "1.0",
createdAt: item.createdAt,
updatedAt: item.updatedAt,
teacherName: item.teacherName,
subjectName: item.subject,
gradeName: item.gradeLevel,
};
}
/** 选修课状态中文标签映射 */ /** 选修课状态中文标签映射 */
export const ELECTIVE_STATUS_LABEL: Record<string, string> = { export const ELECTIVE_STATUS_LABEL: Record<string, string> = {

View File

@@ -26,12 +26,7 @@ import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { import { ExamDataTable } from "./components/exam-data-table";
formatExamDate,
formatExamStatus,
formatDuration,
parseTotalScore,
} from "@/features/teacher/exams/transformations";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -130,97 +125,7 @@ export function ExamsListClient(): React.ReactElement {
</div> </div>
} }
> >
<ExamsTable items={filteredItems} /> <ExamDataTable data={filteredItems} />
</ListPageShell> </ListPageShell>
); );
} }
/**
* 考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ExamsTable({ items }: { items: ExamListItem[] }): React.ReactElement {
const t = useTranslations("exams");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colExamDate")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colDuration")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colTotalScore")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((exam) => (
<tr key={exam.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/exams/${exam.id}`}
className="font-medium hover:underline"
>
{exam.title}
</Link>
{exam.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{exam.description}
</p>
) : null}
</td>
<td className="p-3">
<ExamStatusBadge status={exam.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatExamDate(exam.examDate)}
</td>
<td className="p-3 text-xs">{formatDuration(exam.duration)}</td>
<td className="p-3">
{parseTotalScore(exam.totalScore)} {t("list.unitScore")}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/exams/${exam.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 考试状态徽章(按状态色阶展示)。
*/
function ExamStatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatExamStatus(status);
const cls =
status === "DRAFT"
? "bg-muted text-muted-foreground"
: status === "PUBLISHED" || status === "IN_PROGRESS"
? "bg-primary/10 text-primary"
: status === "SCORED"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground";
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -56,6 +56,14 @@ import {
formatExamDate, formatExamDate,
} from "@/features/teacher/exams/transformations"; } from "@/features/teacher/exams/transformations";
// P2 迁移:反作弊与考试模式配置组件(监考工作台配套)
export { AntiCheatMonitor } from "./components/anti-cheat-monitor";
export { ExamModeConfig } from "./components/exam-mode-config";
export type {
ExamMode,
ExamModeConfigFieldValues,
} from "./components/exam-mode-config";
/** /**
* 监考工作台客户端主体。需由 server page 包裹在 <Suspense> 中。 * 监考工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
*/ */

View File

@@ -246,3 +246,86 @@ export function toGradeListItem(
gradedBy: grade.gradedBy, gradedBy: grade.gradedBy,
}; };
} }
// --- 迁移自 CICD src/modules/grades/lib/grade-utils.ts ---
/**
* Safely convert an unknown value to a finite number.
* Returns 0 when the value is not a finite number.
*
* Used to normalize numeric columns returned by data layer (which may be
* string | number depending on the source) into plain numbers.
*/
export function toNumber(v: unknown): number {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* Normalize a raw score to a 0-100 scale based on its full score.
* Returns 0 when fullScore is non-positive. Result is rounded to 2 decimals.
*/
export function normalize(score: number, fullScore: number): number {
if (fullScore <= 0) return 0;
return Math.round((score / fullScore) * 10000) / 100;
}
/**
* 计算数组均值。空数组返回 0。
*/
export function calcAverage(values: ReadonlyArray<number>): number {
if (values.length === 0) return 0;
const sum = values.reduce((acc, v) => acc + v, 0);
return sum / values.length;
}
/**
* 计算数组中位数。空数组返回 0。
*/
export function calcMedian(values: ReadonlyArray<number>): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? sorted[mid]!
: (sorted[mid - 1]! + sorted[mid]!) / 2;
}
/**
* 计算数组标准差(总体标准差)。空数组返回 0。
*/
export function calcStdDev(values: ReadonlyArray<number>): number {
if (values.length === 0) return 0;
const avg = calcAverage(values);
const variance =
values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length;
return Math.sqrt(variance);
}
/**
* 计算及格率score/fullScore >= 0.6)。空数组返回 0。
* 返回 0-1 的小数。
*/
export function calcPassRate(
scores: ReadonlyArray<{ score: number; fullScore: number }>,
): number {
if (scores.length === 0) return 0;
const passCount = scores.filter(
(s) => s.fullScore > 0 && s.score / s.fullScore >= 0.6,
).length;
return passCount / scores.length;
}
/**
* 计算优秀率score/fullScore >= 0.85)。空数组返回 0。
* 返回 0-1 的小数。
*/
export function calcExcellentRate(
scores: ReadonlyArray<{ score: number; fullScore: number }>,
): number {
if (scores.length === 0) return 0;
const excellentCount = scores.filter(
(s) => s.fullScore > 0 && s.score / s.fullScore >= 0.85,
).length;
return excellentCount / scores.length;
}

View File

@@ -47,6 +47,7 @@ import {
isOverdue, isOverdue,
submissionStatusToBadgeClass, submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations"; } from "@/features/teacher/homework/transformations";
import { ExcellentSubmissions } from "@/features/teacher/homework/components/excellent-submissions";
/** /**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -178,6 +179,8 @@ function HomeworkDetailBody({
<DetailSection title={t("detail.sectionInlineGrade")}> <DetailSection title={t("detail.sectionInlineGrade")}>
<InlineGradeForm homeworkId={homework.id} /> <InlineGradeForm homeworkId={homework.id} />
</DetailSection> </DetailSection>
<ExcellentSubmissions items={[]} />
</> </>
); );
} }

View File

@@ -33,6 +33,8 @@ import {
WorkbenchPageSkeleton, WorkbenchPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify"; import { notify } from "@/shared/lib/notify";
import { ScanImageViewer } from "@/features/teacher/homework/components/scan-image-viewer";
import type { ScanImage } from "@/features/teacher/homework/components/scan-uploader";
import { import {
confidenceToColorClass, confidenceToColorClass,
formatConfidence, formatConfidence,
@@ -114,7 +116,9 @@ export function ScanGradingClient(): React.ReactElement {
/** /**
* 扫描图片预览面板(左栏)。 * 扫描图片预览面板(左栏)。
* 注:当前无真实扫描图片,使用占位符 + 提示 * 注:当前无真实扫描图片 API使用 ScanImageViewer 渲染空态
* 后端补齐 scanAttachments 查询后,传入真实 images。
* @contract-pendingscanAttachments 查询契约待补齐。
*/ */
function ScanPreviewPanel({ function ScanPreviewPanel({
submissionId, submissionId,
@@ -122,19 +126,19 @@ function ScanPreviewPanel({
submissionId: string; submissionId: string;
}): React.ReactElement { }): React.ReactElement {
const t = useTranslations("homework"); const t = useTranslations("homework");
// @contract-pending后端补齐后改为 useSubmissionScans(submissionId) 拉取
const scans: ScanImage[] = [];
return ( return (
<div className="flex h-full flex-col gap-3"> <div className="flex h-full flex-col gap-3">
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4"> <div className="min-h-[300px] flex-1">
<div className="text-center"> <ScanImageViewer images={scans} />
<ScanLine className="mx-auto size-12 text-muted-foreground" />
<p className="mt-2 text-xs text-muted-foreground">
{t("scan.imagePlaceholder", { id: submissionId })}
</p>
</div>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("scan.imageContractPending")} {t("scan.imageContractPending")}
</p> </p>
<p className="text-xs text-muted-foreground">
{t("scan.imagePlaceholder", { id: submissionId })}
</p>
</div> </div>
); );
} }

View File

@@ -5,10 +5,15 @@
* *
* 数据契约: * 数据契约:
* - 列表查询 practices(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - 列表查询 practices(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 自适应练习会话查询/创建 ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#practice-list * - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#practice-list
* *
* URL 状态:?classId=xxx &subjectId=xxx &status=xxx &q=xxx * URL 状态:?classId=xxx &subjectId=xxx &status=xxx &q=xxx
* *
* 标签页结构:
* - 练习与作业:教师布置的练习列表(原有功能)
* - 自适应练习:发起自适应练习 + 查看本人练习历史(迁移自 CICD adaptive-practice
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState 含 next action * 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState 含 next action
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
@@ -18,13 +23,29 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { usePractices, type PracticeListItem } from "@/lib/api"; import {
useAdaptivePracticeSessions,
useKnowledgePoints,
usePractices,
type PracticeListItem,
} from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import {
FilterBar,
FilterSearchInput,
} from "@/shared/components/ui/filter-bar";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { Skeleton } from "@/shared/components/ui/skeleton";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { Card, CardContent } from "@/shared/components/ui/card";
import { import {
formatAvgScore, formatAvgScore,
formatPracticeDate, formatPracticeDate,
@@ -33,6 +54,8 @@ import {
isPracticeOverdue, isPracticeOverdue,
practiceStatusToBadgeClass, practiceStatusToBadgeClass,
} from "@/features/teacher/practice/transformations"; } from "@/features/teacher/practice/transformations";
import { PracticeHistory } from "./components/practice-history";
import { PracticeStarter } from "./components/practice-starter";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -40,6 +63,36 @@ import {
*/ */
export function PracticeListClient(): React.ReactElement { export function PracticeListClient(): React.ReactElement {
const t = useTranslations("practice"); const t = useTranslations("practice");
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ClipboardList className="size-6" />}
>
<Tabs defaultValue="assignments" className="w-full">
<TabsList>
<TabsTrigger value="assignments">{t("tabs.assignments")}</TabsTrigger>
<TabsTrigger value="adaptive">{t("tabs.adaptive")}</TabsTrigger>
</TabsList>
<TabsContent value="assignments" className="space-y-4">
<AssignmentsPanel />
</TabsContent>
<TabsContent value="adaptive" className="space-y-6">
<AdaptivePracticePanel />
</TabsContent>
</Tabs>
</ListPageShell>
);
}
/**
* 练习与作业面板:原有的列表 + 筛选逻辑。
*/
function AssignmentsPanel(): React.ReactElement {
const t = useTranslations("practice");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -93,12 +146,8 @@ export function PracticeListClient(): React.ReactElement {
) : undefined; ) : undefined;
return ( return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ClipboardList className="size-6" />}
filters={
<> <>
<FilterBar variant="between">
<FilterSearchInput <FilterSearchInput
placeholder={t("list.searchPlaceholder")} placeholder={t("list.searchPlaceholder")}
value={q} value={q}
@@ -132,12 +181,13 @@ export function PracticeListClient(): React.ReactElement {
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option> <option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
<option value="CLOSED">{t("list.statusClosed")}</option> <option value="CLOSED">{t("list.statusClosed")}</option>
</select> </select>
</> </FilterBar>
}
loading={loading} {errorNode ? (
loadingNode={<ListPageSkeleton rows={5} />} errorNode
empty={filteredItems.length === 0 && !loading} ) : loading ? (
emptyNode={ <ListPageSkeleton rows={5} />
) : filteredItems.length === 0 ? (
<EmptyState <EmptyState
title={t("list.emptyTitle")} title={t("list.emptyTitle")}
description={t("list.emptyDescription")} description={t("list.emptyDescription")}
@@ -146,16 +196,109 @@ export function PracticeListClient(): React.ReactElement {
onClick: () => updateQuery("q", ""), onClick: () => updateQuery("q", ""),
}} }}
/> />
} ) : (
errorNode={errorNode} <PracticeTable items={filteredItems} />
pagination={ )}
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span> <span>{t("list.total", { count: filteredItems.length })}</span>
</div> </div>
</>
);
}
/**
* 自适应练习面板:发起练习 + 练习历史(迁移自 CICD adaptive-practice 模块)。
*
* 功能闭环:
* 1. 选择知识点/类型/难度 → 发起练习PracticeStarter
* 2. 创建成功后刷新历史列表refetch
* 3. 练习历史展示所有会话PracticeHistory
*
* @contract-pending 自适应练习会话查询/创建契约待补齐MSW 兜底
*/
function AdaptivePracticePanel(): React.ReactElement {
const t = useTranslations("practice");
// 知识点列表(供 PracticeStarter 选择)
const { data: kpData, loading: kpLoading } = useKnowledgePoints({});
// 当前用户的自适应练习会话历史
const {
data: sessionsData,
loading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = useAdaptivePracticeSessions();
const knowledgePoints = useMemo(
() => (kpData?.items ?? []).map((kp) => ({ id: kp.id, name: kp.title })),
[kpData],
);
const sessions = sessionsData?.items ?? [];
function handleSessionCreated(): void {
// 创建成功后刷新历史列表,形成闭环
refetchSessions();
} }
>
<PracticeTable items={filteredItems} /> return (
</ListPageShell> <div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("adaptive.description")}
</p>
{/* 发起练习区 */}
<section className="space-y-2" aria-label={t("adaptive.starterSection")}>
<h2 className="text-lg font-semibold">
{t("adaptive.starterSection")}
</h2>
{kpLoading ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
<Skeleton className="mx-auto h-4 w-32" aria-hidden="true" />
<p className="mt-2">{t("adaptive.knowledgePointsLoading")}</p>
</CardContent>
</Card>
) : knowledgePoints.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t("adaptive.knowledgePointsEmpty")}
</CardContent>
</Card>
) : (
<PracticeStarter
knowledgePoints={knowledgePoints}
onSessionCreated={handleSessionCreated}
aiRecommendReason="teacher_assigned"
/>
)}
</section>
{/* 练习历史区 */}
<section className="space-y-2" aria-label={t("adaptive.historySection")}>
<h2 className="text-lg font-semibold">
{t("adaptive.historySection")}
</h2>
{sessionsLoading ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
<Skeleton className="mx-auto h-4 w-32" aria-hidden="true" />
<p className="mt-2">{t("adaptive.historyLoading")}</p>
</CardContent>
</Card>
) : sessionsError ? (
<Card>
<CardContent className="py-8 text-center text-sm text-destructive">
{t("adaptive.historyError")}
</CardContent>
</Card>
) : (
<PracticeHistory sessions={sessions} />
)}
</section>
</div>
); );
} }

View File

@@ -36,6 +36,8 @@ import {
questionTypeToBadgeClass, questionTypeToBadgeClass,
truncateContent, truncateContent,
} from "@/features/teacher/questions/transformations"; } from "@/features/teacher/questions/transformations";
import { ImportExportButtons } from "@/features/teacher/questions/components/import-export-buttons";
import { QuestionActions } from "@/features/teacher/questions/components/question-actions";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -110,12 +112,22 @@ export function QuestionsListClient(): React.ReactElement {
description={t("list.description")} description={t("list.description")}
icon={<HelpCircle className="size-6" />} icon={<HelpCircle className="size-6" />}
actions={ actions={
<div className="flex items-center gap-2">
<ImportExportButtons
filter={{
type: typeFilter || undefined,
difficulty: difficultyFilter || undefined,
subjectId: subjectId || undefined,
q: q || undefined,
}}
/>
<Button asChild> <Button asChild>
<Link href="/shell/teacher/questions"> <Link href="/shell/teacher/questions">
<Plus className="mr-1 size-4" /> <Plus className="mr-1 size-4" />
{t("list.new")} {t("list.new")}
</Link> </Link>
</Button> </Button>
</div>
} }
filters={ filters={
<> <>
@@ -248,12 +260,7 @@ function QuestionsTable({
{formatQuestionDate(q.createdAt)} {formatQuestionDate(q.createdAt)}
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
<Link <QuestionActions question={q} />
href={`/shell/teacher/questions?id=${q.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -5,6 +5,8 @@
* *
* 数据契约: * 数据契约:
* - 列表查询 scheduleChanges(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending * - 列表查询 scheduleChanges(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 创建 mutation createScheduleChange(input) ❌ schema 无 Mutation 类型 → MSW 兜底
* - 审批/驳回 mutation ❌ 无对应 hook → @contract-pending本地 stub 暂不写入
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#schedule-changes-list * - 契约工单docs/architecture/issues/contracts/classes_contract.md#schedule-changes-list
* *
* URL 状态:?classId=xxx &status=xxx &q=xxx * URL 状态:?classId=xxx &status=xxx &q=xxx
@@ -13,27 +15,38 @@
* *
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4 * 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
*/ */
import { CalendarClock } from "lucide-react"; import { CalendarClock, Plus } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useScheduleChanges, type ScheduleChangeListItem } from "@/lib/api"; import { notify } from "@/shared/lib/notify";
import {
useClasses,
useCreateScheduleChange,
useScheduleChanges,
} from "@/lib/api";
import type { ScheduleChangeListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { import {
ListPageShell, ListPageShell,
ListPageSkeleton, ListPageSkeleton,
} from "@/shared/components/page-templates"; } from "@/shared/components/page-templates";
import { ScheduleChangeForm } from "./components/schedule-change-form";
import { import {
formatScheduleChangeDate, ScheduleChangeList,
formatScheduleChangeDay, type ScheduleChangeReviewSubmitInput,
formatScheduleChangeStatus, } from "./components/schedule-change-list";
formatScheduleChangeType,
getScheduleChangeSummary,
scheduleChangeStatusToBadgeClass,
scheduleChangeTypeToBadgeClass,
} from "@/features/teacher/schedule-changes/transformations";
/** /**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中 * 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -45,18 +58,42 @@ export function ScheduleChangesListClient(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [createOpen, setCreateOpen] = useState(false);
const [reviewing, setReviewing] = useState(false);
const classId = searchParams.get("classId") ?? ""; const classId = searchParams.get("classId") ?? "";
const statusFilter = searchParams.get("status") ?? ""; const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底 // @contract-pendingMSW 兜底
const { data, loading, error } = useScheduleChanges({ const { data, loading, error, refetch } = useScheduleChanges({
classId: classId || undefined, classId: classId || undefined,
status: statusFilter || undefined, status: statusFilter || undefined,
q: q || undefined, q: q || undefined,
}); });
// @contract-pendingMSW 兜底
const { run: createScheduleChange, loading: creating } =
useCreateScheduleChange();
// 班级下拉数据(复用 classes 域的 useClasses hook通过 @/lib/api 统一出口)
// @contract-pendingMSW 兜底
const { data: classesData } = useClasses({});
const classOptions = useMemo(
() =>
(classesData?.items ?? []).map((c) => ({
id: c.id,
name: c.name,
gradeId: c.gradeId,
})),
[classesData],
);
// 教师下拉数据portal-shell 无全局 useTeachers hook@contract-pending
// 现有 useClassTeachers 为按班级查询,且需要 classId待补齐全局教师列表查询后切换
// 表单中教师下拉暂时为空,用户可手填或跳过(表单字段为可选)
const teacherOptions: { id: string; name: string; email: string }[] = [];
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选 // 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<ScheduleChangeListItem[]>(() => { const filteredItems = useMemo<ScheduleChangeListItem[]>(() => {
const items = data?.items ?? []; const items = data?.items ?? [];
@@ -80,6 +117,43 @@ export function ScheduleChangesListClient(): React.ReactElement {
}); });
}; };
const handleCreate = async (input: {
classId: string;
changeType: "RESCHEDULE" | "CANCEL" | "SUBSTITUTE" | "MERGE";
originalLesson: string;
originalDate: string;
reason: string;
newDate?: string;
newPeriod?: string;
}): Promise<void> => {
await createScheduleChange({
classId: input.classId,
originalLesson: input.originalLesson,
originalDate: input.originalDate,
changeType: input.changeType,
newDate: input.newDate,
newPeriod: input.newPeriod,
reason: input.reason,
});
notify.success(t("form.createSuccess"));
setCreateOpen(false);
refetch?.();
};
// @contract-pending审批/驳回 mutation 尚未补齐,本地 stub 不写入
const handleSubmitReview = async (
_input: ScheduleChangeReviewSubmitInput,
): Promise<void> => {
setReviewing(true);
try {
// TODO: 后端补齐 approve/reject mutation 后切换为真实调用
await new Promise((resolve) => setTimeout(resolve, 300));
notify.success(t("review.stubSuccess"));
} finally {
setReviewing(false);
}
};
const errorNode = error ? ( const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -96,6 +170,12 @@ export function ScheduleChangesListClient(): React.ReactElement {
title={t("list.title")} title={t("list.title")}
description={t("list.description")} description={t("list.description")}
icon={<CalendarClock className="size-6" />} icon={<CalendarClock className="size-6" />}
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("list.createAction")}
</Button>
}
filters={ filters={
<> <>
<FilterSearchInput <FilterSearchInput
@@ -145,119 +225,29 @@ export function ScheduleChangesListClient(): React.ReactElement {
</div> </div>
} }
> >
<ScheduleChangeTable items={filteredItems} /> <ScheduleChangeList
items={filteredItems}
canApprove
onSubmitReview={handleSubmitReview}
onReviewed={() => refetch?.()}
submitting={reviewing}
/>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{t("form.dialogTitle")}</DialogTitle>
<DialogDescription>{t("form.dialogDescription")}</DialogDescription>
</DialogHeader>
<ScheduleChangeForm
classes={classOptions}
teachers={teacherOptions}
onSubmit={handleCreate}
submitting={creating}
onCancel={() => setCreateOpen(false)}
/>
</DialogContent>
</Dialog>
</ListPageShell> </ListPageShell>
); );
} }
/**
* 调课列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ScheduleChangeTable({
items,
}: {
items: ScheduleChangeListItem[];
}): React.ReactElement {
const t = useTranslations("scheduleChanges");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("list.colSummary")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colClassName")}
</th>
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
<th className="p-3 text-left font-medium">
{t("list.colOriginalDate")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colNewDate")}
</th>
<th className="p-3 text-left font-medium">{t("list.colReason")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colApplicant")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colCreatedAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((change) => (
<tr key={change.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">
{getScheduleChangeSummary(change)}
</td>
<td className="p-3 text-xs">{change.className}</td>
<td className="p-3">
<ScheduleChangeTypeBadge type={change.changeType} />
</td>
<td className="p-3 font-mono text-xs">
{formatScheduleChangeDay(change.originalDate)}
</td>
<td className="p-3 font-mono text-xs">
{formatScheduleChangeDay(change.newDate)}
{change.newPeriod ? ` ${change.newPeriod}` : ""}
</td>
<td className="p-3 text-xs">
<p className="max-w-xs truncate" title={change.reason}>
{change.reason}
</p>
</td>
<td className="p-3">
<ScheduleChangeStatusBadge status={change.status} />
</td>
<td className="p-3 text-xs">{change.applicantName}</td>
<td className="p-3 font-mono text-xs">
{formatScheduleChangeDate(change.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 调课状态徽章。
*/
function ScheduleChangeStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatScheduleChangeStatus(status);
const cls = scheduleChangeStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
/**
* 调课类型徽章。
*/
function ScheduleChangeTypeBadge({
type,
}: {
type: string;
}): React.ReactElement {
const label = formatScheduleChangeType(type);
const cls = scheduleChangeTypeToBadgeClass(type);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -38,6 +38,7 @@ import {
isTextbookEditable, isTextbookEditable,
sortChaptersByOrder, sortChaptersByOrder,
} from "@/features/teacher/textbooks/transformations"; } from "@/features/teacher/textbooks/transformations";
import { KnowledgeGraph } from "./components/knowledge-graph";
/** /**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 * 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
@@ -111,10 +112,33 @@ export function TextbookDetailClient(): React.ReactElement {
mswNotice={t("detail.chaptersMswNotice")} mswNotice={t("detail.chaptersMswNotice")}
/> />
) : null} ) : null}
{data ? <KnowledgeGraphSection textbookId={data.id} /> : null}
</DetailPageShell> </DetailPageShell>
); );
} }
/**
* 知识图谱区(详情页内嵌,使用迁移后的 KnowledgeGraph 组件)。
*
* 容器使用 h-96Tailwind 默认阶梯 24rem = 384px
* 内部 KnowledgeGraph 通过 h-full 撑满容器force-graph 同样使用
* ResizeObserver 自适应容器尺寸。遵守 §3.10 设计令牌:不使用任意值。
*/
function KnowledgeGraphSection({
textbookId,
}: {
textbookId: string;
}): React.ReactElement {
const t = useTranslations("textbooks");
return (
<DetailSection title={t("detail.sectionKnowledgeGraph")}>
<div className="h-96 rounded-xl border bg-background overflow-hidden">
<KnowledgeGraph textbookId={textbookId} />
</div>
</DetailSection>
);
}
/** /**
* 详情基本信息区(对齐 §7.3 详情页模板)。 * 详情基本信息区(对齐 §7.3 详情页模板)。
*/ */

View File

@@ -2327,31 +2327,21 @@ export function useUpdateViewport(): {
// ============================================================ // ============================================================
export function useAdminStudents( export function useAdminStudents(
filter: StudentFilter, filter: StudentFilter,
pagination: Pagination,
): UseQueryResult<PaginatedResult<AdminStudent>> { ): UseQueryResult<PaginatedResult<AdminStudent>> {
const result = useWidgetQuery< const result = useWidgetQuery<
{ adminStudents: PaginatedResult<AdminStudent> }, { adminStudents: PaginatedResult<AdminStudent> },
{ filter: StudentFilter; limit: number; offset: number } { filter: StudentFilter }
>(GET_ADMIN_STUDENTS_DOC, { >(GET_ADMIN_STUDENTS_DOC, { filter });
filter,
limit: pagination.limit,
offset: pagination.offset,
});
return { ...result, data: result.data?.adminStudents }; return { ...result, data: result.data?.adminStudents };
} }
export function useAdminTeachers( export function useAdminTeachers(
filter: TeacherFilter, filter: TeacherFilter,
pagination: Pagination,
): UseQueryResult<PaginatedResult<AdminTeacher>> { ): UseQueryResult<PaginatedResult<AdminTeacher>> {
const result = useWidgetQuery< const result = useWidgetQuery<
{ adminTeachers: PaginatedResult<AdminTeacher> }, { adminTeachers: PaginatedResult<AdminTeacher> },
{ filter: TeacherFilter; limit: number; offset: number } { filter: TeacherFilter }
>(GET_ADMIN_TEACHERS_DOC, { >(GET_ADMIN_TEACHERS_DOC, { filter });
filter,
limit: pagination.limit,
offset: pagination.offset,
});
return { ...result, data: result.data?.adminTeachers }; return { ...result, data: result.data?.adminTeachers };
} }

View File

@@ -19,8 +19,12 @@ import { ApiError } from "./errors";
import type { PaginatedResult, Pagination, UseQueryResult } from "./types"; import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
import { import {
GET_USERS_DOC, GET_USERS_DOC,
GET_USER_DOC,
UPDATE_USER_STATUS_DOC, UPDATE_USER_STATUS_DOC,
UPDATE_USER_ROLE_DOC, UPDATE_USER_ROLE_DOC,
UPDATE_USER_DOC,
DELETE_USER_DOC,
ASSIGN_USER_ROLES_DOC,
GET_ROLES_DOC, GET_ROLES_DOC,
GET_PERMISSIONS_DOC, GET_PERMISSIONS_DOC,
UPDATE_ROLE_PERMISSIONS_DOC, UPDATE_ROLE_PERMISSIONS_DOC,
@@ -28,6 +32,8 @@ import {
GET_INVITATION_CODES_DOC, GET_INVITATION_CODES_DOC,
CREATE_INVITATION_CODE_DOC, CREATE_INVITATION_CODE_DOC,
REVOKE_INVITATION_CODE_DOC, REVOKE_INVITATION_CODE_DOC,
GENERATE_INVITATION_CODES_DOC,
DELETE_INVITATION_CODES_DOC,
GET_SCHOOL_DOC, GET_SCHOOL_DOC,
UPDATE_SCHOOL_DOC, UPDATE_SCHOOL_DOC,
GET_PLUGIN_REGISTRY_DOC, GET_PLUGIN_REGISTRY_DOC,
@@ -50,14 +56,48 @@ export interface User {
role: string; role: string;
status: string; status: string;
createdAt: string; createdAt: string;
/**
* 手机号(@contract-pendingschema 未就绪MSW 兜底)。
* 缺省视为未知UI 用 "-" 占位。
*/
phone?: string;
/**
* 多角色列表(@contract-pendingschema 未就绪MSW 兜底)。
* 替换语义:与 `role` 共存UI 优先渲染 `roles`,缺省回退 `[role]`。
*/
roles?: string[];
/**
* 更新时间 ISO 字符串(列表查询可选返回)。
* 缺省视为未知UI 用 `-` 占位。
* @contract-pending schema 无此字段MSW 兜底
*/
updatedAt?: string;
/**
* 用户类型internal/external@contract-pendingschema 未就绪MSW 兜底)。
* 缺省视为未知UI 不渲染类型徽章。
*/
userType?: string;
} }
export type UserQueryVars = { export type UserQueryVars = {
role: string | null; role: string | null;
limit: number; /** @contract-pending 真实分页待 IAM 契约就绪MSW 兜底时忽略 */
offset: number; limit?: number;
offset?: number;
}; };
/**
* 用户编辑输入(@contract-pendingschema 未就绪MSW 兜底)。
* 所有字段可选,仅传入需变更的字段。
*/
export interface UserUpdateInput {
name?: string;
email?: string;
phone?: string;
role?: string;
status?: string;
}
// ============================================================ // ============================================================
// Types: RBAC // Types: RBAC
// ============================================================ // ============================================================
@@ -67,6 +107,11 @@ export interface Permission {
resource: string; resource: string;
action: string; action: string;
description: string; description: string;
/**
* 权限值(如 `user:create`@contract-pendingschema 未就绪MSW 兜底)。
* 缺省视为未知UI 用 `-` 占位。
*/
value?: string;
} }
export interface RolePermission { export interface RolePermission {
@@ -90,6 +135,29 @@ export interface Role {
* 列表 MSW 兜底可能缺省UI 需容忍 undefined。 * 列表 MSW 兜底可能缺省UI 需容忍 undefined。
*/ */
isLocked?: boolean; isLocked?: boolean;
/**
* 是否启用(列表查询可选返回)。
* 缺省视为 true兼容旧 MSW 数据)。
* @contract-pending schema 无此字段MSW 兜底
*/
isEnabled?: boolean;
/**
* 关联用户数(列表查询可选返回)。
* 缺省视为 0。
* @contract-pending schema 无此字段MSW 兜底
*/
userCount?: number;
/**
* 更新时间 ISO 字符串(列表查询可选返回)。
* 缺省视为未知UI 用 "-" 占位。
* @contract-pending schema 无此字段MSW 兜底
*/
updatedAt?: string;
/**
* 角色值(如 `role:teacher`@contract-pendingschema 未就绪MSW 兜底)。
* 缺省视为未知UI 用 `-` 占位。
*/
value?: string;
} }
// ============================================================ // ============================================================
@@ -111,6 +179,11 @@ export interface AuditLog {
* 列表 StatusBadge 显示 "--" 占位;契约补齐后切换为真实值。 * 列表 StatusBadge 显示 "--" 占位;契约补齐后切换为真实值。
*/ */
status?: string | null; status?: string | null;
/**
* 错误信息status=failure/error 时有值)。
* @contract-pending schema 未提供此字段MSW 兜底;详情对话框展示用。
*/
errorMessage?: string | null;
} }
export interface AuditLogFilter { export interface AuditLogFilter {
@@ -126,9 +199,25 @@ export interface InvitationCode {
id: string; id: string;
code: string; code: string;
role: string; role: string;
/** 角色展示名(@contract-pendingMSW 兜底) */
roleName?: string | null;
/** 关联班级 ID@contract-pending可选 */
classId?: string | null;
/** 关联班级名称(@contract-pending可选 */
className?: string | null;
/** 关联邮箱(@contract-pending可选 */
email?: string | null;
/** 批次 ID@contract-pendingMSW 兜底) */
batchId?: string | null;
status: string; status: string;
usedCount: number; usedCount: number;
maxUses: number; maxUses: number;
/** 使用者用户 ID@contract-pendingMSW 兜底) */
usedBy?: string | null;
/** 使用者姓名(@contract-pendingMSW 兜底) */
usedByName?: string | null;
/** 使用时间 ISO@contract-pendingMSW 兜底) */
usedAt?: string | null;
expiresAt: string; expiresAt: string;
createdAt: string; createdAt: string;
createdBy: string; createdBy: string;
@@ -145,6 +234,51 @@ export type CreatedInvitationCode = Pick<
"id" | "code" | "role" | "maxUses" | "expiresAt" "id" | "code" | "role" | "maxUses" | "expiresAt"
>; >;
/**
* 批量生成邀请码输入(对齐 CICD GenerateInvitationCodesInput
*
* 字段说明:
* - batchName批次名称可选用于标识本次生成用途
* - count生成数量1-1000
* - role角色代码teacher/student/parent/admin
* - expireDays过期天数与 expiresAt 二选一expireDays 优先)
* - expiresAtISO 过期时间(与 expireDays 二选一)
* - email关联邮箱可选CICD 字段)
* - classId关联班级 ID可选CICD 字段)
* - note备注可选
* - purpose用途说明可选如"新教师入职"
*/
export interface GenerateInvitationCodesInput {
count: number;
role: string;
batchName?: string;
expireDays?: number;
expiresAt?: string;
email?: string;
classId?: string;
note?: string;
purpose?: string;
}
export interface GeneratedInvitationCode {
code: string;
role: string;
expiresAt: string;
}
export interface GenerateInvitationCodesResult {
success: boolean;
/** 批次 ID@contract-pendingMSW 兜底) */
batchId?: string;
generated: GeneratedInvitationCode[];
}
/** 批量删除邀请码结果(@contract-pendingMSW 兜底) */
export interface DeleteInvitationCodesResult {
success: boolean;
deletedCount: number;
}
// ============================================================ // ============================================================
// Types: School // Types: School
// ============================================================ // ============================================================
@@ -169,6 +303,8 @@ export interface SchoolInput {
currentTerm?: string; currentTerm?: string;
semesterStart?: string; semesterStart?: string;
semesterEnd?: string; semesterEnd?: string;
/** 学校编码(@contract-pendingMSW 兜底) */
code?: string;
} }
// ============================================================ // ============================================================
@@ -252,6 +388,18 @@ export function useUsers(
}; };
} }
/**
* 单查用户详情(@contract-pendingschema 无 user(id) 根字段MSW 兜底)。
* 契约工单iam_contract.md#user-detail
*/
export function useUser(id: string): UseQueryResult<User | null> {
const result = useWidgetQuery<{ user: User | null }, { id: string }>(
GET_USER_DOC,
{ id },
);
return { ...result, data: result.data?.user ?? null };
}
export function useUpdateUserStatus(): { export function useUpdateUserStatus(): {
run: (id: string, status: string) => Promise<{ id: string; status: string }>; run: (id: string, status: string) => Promise<{ id: string; status: string }>;
loading: boolean; loading: boolean;
@@ -308,6 +456,101 @@ export function useUpdateUserRole(): {
return { run, loading, error }; return { run, loading, error };
} }
/**
* 删除用户(@contract-pendingMSW 兜底schema 无 deleteUser 根字段)。
* 契约工单iam_contract.md#user-delete
*/
export function useDeleteUser(): {
run: (id: string) => Promise<{ id: string; success: boolean }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ deleteUser: { id: string; success: boolean } },
{ id: string }
>(DELETE_USER_DOC);
const run = async (id: string): Promise<{ id: string; success: boolean }> => {
const data = await rawRun({ id });
if (!data?.deleteUser) {
throw new ApiError("Failed to delete user", "INTERNAL_ERROR");
}
return data.deleteUser;
};
return { run, loading, error };
}
/**
* 多角色分配(@contract-pendingMSW 兜底schema 无 assignUserRoles 根字段)。
* 替换语义:传入完整角色名数组,覆盖原有角色。
* 契约工单iam_contract.md#user-assign-roles
*/
export function useAssignUserRoles(): {
run: (
userId: string,
roleNames: string[],
) => Promise<{ userId: string; roleNames: string[] }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ assignUserRoles: { userId: string; roleNames: string[] } },
{ userId: string; roleNames: string[] }
>(ASSIGN_USER_ROLES_DOC);
const run = async (
userId: string,
roleNames: string[],
): Promise<{ userId: string; roleNames: string[] }> => {
const data = await rawRun({ userId, roleNames });
if (!data?.assignUserRoles) {
throw new ApiError("Failed to assign user roles", "INTERNAL_ERROR");
}
return data.assignUserRoles;
};
return { run, loading, error };
}
/**
* 编辑用户基本信息name/email/phone/role/status
* @contract-pendingschema 无 updateUser 根字段MSW 兜底。
* 契约工单iam_contract.md#user-update
*/
export function useUpdateUser(): {
run: (id: string, input: UserUpdateInput) => Promise<User>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ updateUser: User },
{ id: string; input: UserUpdateInput }
>(UPDATE_USER_DOC);
const run = async (id: string, input: UserUpdateInput): Promise<User> => {
const data = await rawRun({ id, input });
if (!data?.updateUser) {
throw new ApiError("Failed to update user", "INTERNAL_ERROR");
}
return data.updateUser;
};
return { run, loading, error };
}
// ============================================================ // ============================================================
// Hooks: RBAC // Hooks: RBAC
// ============================================================ // ============================================================
@@ -450,6 +693,69 @@ export function useRevokeInvitationCode(): {
return { run, loading, error }; return { run, loading, error };
} }
export function useGenerateInvitationCodes(): {
run: (
input: GenerateInvitationCodesInput,
) => Promise<GenerateInvitationCodesResult>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ generateInvitationCodes: GenerateInvitationCodesResult },
{ input: GenerateInvitationCodesInput }
>(GENERATE_INVITATION_CODES_DOC);
const run = async (
input: GenerateInvitationCodesInput,
): Promise<GenerateInvitationCodesResult> => {
const data = await rawRun({ input });
if (!data?.generateInvitationCodes) {
throw new ApiError(
"Failed to generate invitation codes",
"INTERNAL_ERROR",
);
}
return data.generateInvitationCodes;
};
return { run, loading, error };
}
/**
* 批量删除邀请码(@contract-pendingMSW 兜底)。
* 对齐 CICD deleteInvitationCodes接受 ID 列表,返回删除条数。
*/
export function useDeleteInvitationCodes(): {
run: (ids: string[]) => Promise<DeleteInvitationCodesResult>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ deleteInvitationCodes: number }, { ids: string[] }>(
DELETE_INVITATION_CODES_DOC,
);
const run = async (ids: string[]): Promise<DeleteInvitationCodesResult> => {
const data = await rawRun({ ids });
if (data?.deleteInvitationCodes === undefined) {
throw new ApiError("Failed to delete invitation codes", "INTERNAL_ERROR");
}
return {
success: true,
deletedCount: data.deleteInvitationCodes,
};
};
return { run, loading, error };
}
// ============================================================ // ============================================================
// Hooks: School settings // Hooks: School settings
// ============================================================ // ============================================================

View File

@@ -25,6 +25,7 @@ import {
GET_ATTENDANCE_REPORT_DOC, GET_ATTENDANCE_REPORT_DOC,
GET_ATTENDANCE_SHEET_DOC, GET_ATTENDANCE_SHEET_DOC,
GET_ATTENDANCE_STATS_DOC, GET_ATTENDANCE_STATS_DOC,
GET_CLASS_COMPARISON_DOC,
SAVE_ATTENDANCE_SHEET_DOC, SAVE_ATTENDANCE_SHEET_DOC,
} from "./operations/attendance.graphql"; } from "./operations/attendance.graphql";
import type { UseQueryResult } from "./types"; import type { UseQueryResult } from "./types";
@@ -382,3 +383,38 @@ export function useSaveAttendanceSheet(): {
return { run, loading, error }; return { run, loading, error };
} }
// ===== 班级对比(@contract-pending=====
/** 班级对比项(与 GET_CLASS_COMPARISON_DOC 返回结构一致) */
export interface ClassComparisonItem {
className: string;
attendanceRate: number;
totalStudents: number;
presentStudents: number;
}
interface ClassComparisonResponse {
classComparison: ClassComparisonItem[];
}
/**
* 查询班级出勤率对比(@contract-pendingMSW 兜底)。
*
* schema 无 classComparison 根字段,由 MSW handlers 返回 mock 数据。
* 管理端考勤页班级对比卡通过此 hook 获取数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.4 / §11.4 契约工单
*/
export function useClassComparison(): UseQueryResult<ClassComparisonItem[]> {
const result = useWidgetQuery<ClassComparisonResponse, Record<string, never>>(
GET_CLASS_COMPARISON_DOC,
{},
);
return {
data: result.data?.classComparison ?? [],
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -18,12 +18,23 @@
import type { FetchPolicy } from "@apollo/client"; import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery"; import { useWidgetQuery } from "../useWidgetQuery";
import { useWidgetMutation } from "../useWidgetMutation";
import { ApiError } from "./errors";
import { import {
CREATE_CLASS_DOC,
CREATE_CLASS_SCHEDULE_DOC,
DELETE_CLASS_SCHEDULE_DOC,
GENERATE_CLASS_INVITATION_CODE_DOC,
GET_CLASS_INFO_DOC, GET_CLASS_INFO_DOC,
GET_CLASS_INVITATION_CODES_DOC,
GET_CLASS_SCHEDULE_DOC, GET_CLASS_SCHEDULE_DOC,
GET_CLASS_STUDENTS_DOC, GET_CLASS_STUDENTS_DOC,
GET_CLASS_TEACHERS_DOC, GET_CLASS_TEACHERS_DOC,
GET_CLASSES_DOC, GET_CLASSES_DOC,
REVOKE_CLASS_INVITATION_CODE_DOC,
UPDATE_CLASS_DOC,
DELETE_CLASS_DOC,
UPDATE_CLASS_SCHEDULE_DOC,
} from "./operations/classes.graphql"; } from "./operations/classes.graphql";
import type { UseQueryResult } from "./types"; import type { UseQueryResult } from "./types";
@@ -318,3 +329,374 @@ export function useClassTeachers(
refetch: result.refetch, refetch: result.refetch,
}; };
} }
// ===== 课表 CRUD 输入类型(@contract-pending=====
/** 创建/更新课表条目输入 */
export interface ClassScheduleInput {
classId: string;
weekday: number;
period: number;
subjectName: string;
teacherName: string;
classroom: string | null;
startTime: string;
endTime: string;
}
// ===== 班级邀请码类型(@contract-pending=====
/** 班级邀请码 */
export interface ClassInvitationCode {
id: string;
code: string;
status: string;
usedCount: number;
maxUses: number | null;
expiresAt: string | null;
createdAt: string;
revokedAt: string | null;
note: string | null;
}
/** 生成班级邀请码输入 */
export interface GenerateClassInvitationCodeInput {
classId: string;
expiresInHours: number | null;
maxUses: number | null;
note: string | null;
}
// ===== 响应类型(@contract-pending=====
interface ClassInvitationCodesResponse {
classInvitationCodes: ClassInvitationCode[];
}
interface CreateClassScheduleResponse {
createClassSchedule: ClassScheduleItem;
}
interface UpdateClassScheduleResponse {
updateClassSchedule: ClassScheduleItem;
}
// ===== Hooks: 课表 CRUD@contract-pendingMSW 兜底)=====
/**
* 查询班级课表条目列表(@contract-pendingMSW 兜底)。
* 复用 GET_CLASS_SCHEDULE_DOC返回 items 数组,便于管理端按条目 CRUD。
*
* 关联ARCHITECTURE.md §5.4 / §9.4 管理端课表编辑 / §11.4 契约工单
*/
export function useClassSchedules(
classId: string,
options?: ClassQueryOptions,
): UseQueryResult<ClassScheduleItem[]> {
const result = useWidgetQuery<ClassScheduleResponse, { classId: string }>(
GET_CLASS_SCHEDULE_DOC,
{ classId },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.classSchedule?.items ?? [],
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建班级课表条目(@contract-pendingMSW 兜底)。
*/
export function useCreateClassSchedule(): {
run: (input: ClassScheduleInput) => Promise<ClassScheduleItem>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
CreateClassScheduleResponse,
{ input: ClassScheduleInput }
>(CREATE_CLASS_SCHEDULE_DOC);
const run = async (input: ClassScheduleInput): Promise<ClassScheduleItem> => {
const data = await rawRun({ input });
if (!data?.createClassSchedule) {
throw new ApiError("Failed to create class schedule", "INTERNAL_ERROR");
}
return data.createClassSchedule;
};
return { run, loading, error };
}
/**
* 更新班级课表条目(@contract-pendingMSW 兜底)。
*/
export function useUpdateClassSchedule(): {
run: (id: string, input: ClassScheduleInput) => Promise<ClassScheduleItem>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateClassScheduleResponse,
{ id: string; input: ClassScheduleInput }
>(UPDATE_CLASS_SCHEDULE_DOC);
const run = async (
id: string,
input: ClassScheduleInput,
): Promise<ClassScheduleItem> => {
const data = await rawRun({ id, input });
if (!data?.updateClassSchedule) {
throw new ApiError("Failed to update class schedule", "INTERNAL_ERROR");
}
return data.updateClassSchedule;
};
return { run, loading, error };
}
/**
* 删除班级课表条目(@contract-pendingMSW 兜底)。
*/
export function useDeleteClassSchedule(): {
run: (id: string) => Promise<boolean>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ deleteClassSchedule: boolean }, { id: string }>(
DELETE_CLASS_SCHEDULE_DOC,
);
const run = async (id: string): Promise<boolean> => {
const data = await rawRun({ id });
if (
data?.deleteClassSchedule === undefined ||
data?.deleteClassSchedule === null
) {
throw new ApiError("Failed to delete class schedule", "INTERNAL_ERROR");
}
return data.deleteClassSchedule;
};
return { run, loading, error };
}
// ===== Hooks: 班级邀请码(@contract-pendingMSW 兜底)=====
/**
* 查询班级邀请码列表(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.4 管理端邀请码管理 / §11.4 契约工单
*/
export function useClassInvitationCodes(
classId: string,
options?: ClassQueryOptions,
): UseQueryResult<ClassInvitationCode[]> {
const result = useWidgetQuery<
ClassInvitationCodesResponse,
{ classId: string }
>(
GET_CLASS_INVITATION_CODES_DOC,
{ classId },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.classInvitationCodes ?? [],
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 生成班级邀请码(@contract-pendingMSW 兜底)。
*/
export function useGenerateClassInvitationCode(): {
run: (
input: GenerateClassInvitationCodeInput,
) => Promise<ClassInvitationCode>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ generateClassInvitationCode: ClassInvitationCode },
{ input: GenerateClassInvitationCodeInput }
>(GENERATE_CLASS_INVITATION_CODE_DOC);
const run = async (
input: GenerateClassInvitationCodeInput,
): Promise<ClassInvitationCode> => {
const data = await rawRun({ input });
if (!data?.generateClassInvitationCode) {
throw new ApiError(
"Failed to generate class invitation code",
"INTERNAL_ERROR",
);
}
return data.generateClassInvitationCode;
};
return { run, loading, error };
}
/**
* 撤销班级邀请码(@contract-pendingMSW 兜底)。
*/
export function useRevokeClassInvitationCode(): {
run: (codeId: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
{ revokeClassInvitationCode: { id: string; status: string } },
{ codeId: string }
>(REVOKE_CLASS_INVITATION_CODE_DOC);
const run = async (
codeId: string,
): Promise<{ id: string; status: string }> => {
const data = await rawRun({ codeId });
if (!data?.revokeClassInvitationCode) {
throw new ApiError(
"Failed to revoke class invitation code",
"INTERNAL_ERROR",
);
}
return data.revokeClassInvitationCode;
};
return { run, loading, error };
}
// ===== 班级 CRUD 输入类型(@contract-pending=====
/** 创建/更新班级输入 */
export interface ClassInput {
name: string;
gradeId: string;
headTeacherId: string | null;
description: string | null;
}
// ===== 响应类型(@contract-pending=====
interface CreateClassResponse {
createClass: ClassInfo;
}
interface UpdateClassResponse {
updateClass: ClassInfo;
}
// ===== Hooks: 班级 CRUD@contract-pendingMSW 兜底)=====
/**
* 创建班级(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.4 管理端班级编辑 / §11.4 契约工单
*/
export function useCreateClass(): {
run: (input: ClassInput) => Promise<ClassInfo>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateClassResponse, { input: ClassInput }>(
CREATE_CLASS_DOC,
);
const run = async (input: ClassInput): Promise<ClassInfo> => {
const data = await rawRun({ input });
if (!data?.createClass) {
throw new ApiError("Failed to create class", "INTERNAL_ERROR");
}
return data.createClass;
};
return { run, loading, error };
}
/**
* 更新班级(@contract-pendingMSW 兜底)。
*/
export function useUpdateClass(): {
run: (id: string, input: ClassInput) => Promise<ClassInfo>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<UpdateClassResponse, { id: string; input: ClassInput }>(
UPDATE_CLASS_DOC,
);
const run = async (id: string, input: ClassInput): Promise<ClassInfo> => {
const data = await rawRun({ id, input });
if (!data?.updateClass) {
throw new ApiError("Failed to update class", "INTERNAL_ERROR");
}
return data.updateClass;
};
return { run, loading, error };
}
/**
* 删除班级(@contract-pendingMSW 兜底)。
*/
export function useDeleteClass(): {
run: (id: string) => Promise<boolean>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<{ deleteClass: boolean }, { id: string }>(
DELETE_CLASS_DOC,
);
const run = async (id: string): Promise<boolean> => {
const data = await rawRun({ id });
if (data?.deleteClass === undefined || data?.deleteClass === null) {
throw new ApiError("Failed to delete class", "INTERNAL_ERROR");
}
return data.deleteClass;
};
return { run, loading, error };
}

View File

@@ -19,6 +19,7 @@ import { ApiError } from "./errors";
import { import {
CREATE_COURSE_PLAN_DOC, CREATE_COURSE_PLAN_DOC,
GET_COURSE_PLAN_DOC, GET_COURSE_PLAN_DOC,
GET_COURSE_PLAN_TEMPLATES_DOC,
GET_COURSE_PLANS_DOC, GET_COURSE_PLANS_DOC,
UPDATE_COURSE_PLAN_DOC, UPDATE_COURSE_PLAN_DOC,
} from "./operations/course-plans.graphql"; } from "./operations/course-plans.graphql";
@@ -77,6 +78,20 @@ export interface CoursePlanListItem {
updatedAt: string; updatedAt: string;
} }
/**
* 课程计划模板(@contract-pendingMSW 兜底)。
*
* 用于 create 页"从模板创建"对话框。模板是可复用的课程计划骨架,
* 字段最小化(不含 units / progress 等详情字段)。
*/
export interface CoursePlanTemplate {
id: string;
name: string;
description: string;
subject: string;
gradeLevel: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */ /** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface CoursePlansListResponse { interface CoursePlansListResponse {
coursePlans: { coursePlans: {
@@ -90,6 +105,11 @@ interface CoursePlanResponse {
coursePlan: CoursePlanDetail | null; coursePlan: CoursePlanDetail | null;
} }
/** 模板列表查询响应(@contract-pendingMSW 返回此结构) */
interface CoursePlanTemplatesResponse {
coursePlanTemplates: CoursePlanTemplate[];
}
/** 创建课程计划输入 */ /** 创建课程计划输入 */
export interface CreateCoursePlanInput { export interface CreateCoursePlanInput {
name: string; name: string;
@@ -130,6 +150,12 @@ export interface CoursePlansListFilter {
offset?: number; offset?: number;
} }
/** 模板查询筛选(按学科 / 年级过滤,均可选) */
export interface CoursePlanTemplatesFilter {
subjectId?: string;
gradeLevel?: string;
}
// ===== 查询选项 ===== // ===== 查询选项 =====
export interface CoursePlanQueryOptions { export interface CoursePlanQueryOptions {
@@ -214,6 +240,41 @@ export function useCoursePlan(
}; };
} }
/**
* 查询课程计划模板列表(@contract-pendingMSW 兜底)。
*
* schema 无 coursePlanTemplates 根字段,由 MSW handlers 返回 mock 数据。
* 用于 create 页"从模板创建"对话框,列出可复用的课程计划模板。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useCoursePlanTemplates(
filter?: CoursePlanTemplatesFilter,
options?: CoursePlanQueryOptions,
): UseQueryResult<CoursePlanTemplate[]> {
const result = useWidgetQuery<
CoursePlanTemplatesResponse,
{ subjectId?: string; gradeLevel?: string }
>(
GET_COURSE_PLAN_TEMPLATES_DOC,
{
subjectId: filter?.subjectId,
gradeLevel: filter?.gradeLevel,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.coursePlanTemplates,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/** /**
* 创建课程计划 mutation@contract-pendingMSW 兜底)。 * 创建课程计划 mutation@contract-pendingMSW 兜底)。
* *

View File

@@ -20,7 +20,10 @@ import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery"; import { useWidgetQuery } from "../useWidgetQuery";
import { GET_ERROR_BOOK_STATS_DOC } from "./operations/dashboard.graphql"; import { GET_ERROR_BOOK_STATS_DOC } from "./operations/dashboard.graphql";
import { GET_ERROR_BOOK_ITEMS_DOC } from "./operations/error-book.graphql"; import {
GET_ERROR_BOOK_ITEMS_DOC,
GET_ERROR_BOOK_DETAIL_DOC,
} from "./operations/error-book.graphql";
import type { UseQueryResult } from "./types"; import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 data-ana 子图snake_case===== // ===== 数据类型(对齐 data-ana 子图snake_case=====
@@ -149,3 +152,309 @@ export function useTeacherErrorBookStats(
refetch: result.refetch, refetch: result.refetch,
}; };
} }
// ===== 错题详情(@contract-pending MSW 兜底)=====
/** 错题详情camelCase 对齐 admin 子图风格,用于详情对话框)*/
export interface ErrorBookItemDetail {
itemId: string;
content: string;
subjectId: string;
subjectName: string;
studentId: string;
studentName: string;
className: string;
errorCount: number;
lastErrorTime: string;
knowledgePointId: string;
knowledgePointTitle: string;
analysis: string;
correctAnswer: string;
}
/** errorBookDetail 查询响应 */
interface ErrorBookDetailResponse {
errorBookDetail: ErrorBookItemDetail | null;
}
/**
* 查询错题详情(@contract-pending MSW 兜底)。
*
* 按 itemId 拉取错题完整详情,用于详情对话框。
* itemId 为空时跳过查询enabled=false
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §9.1
*/
export function useErrorBookDetail(
itemId: string,
options?: ErrorBookQueryOptions,
): UseQueryResult<ErrorBookItemDetail | null> {
const result = useWidgetQuery<ErrorBookDetailResponse, { itemId: string }>(
GET_ERROR_BOOK_DETAIL_DOC,
{ itemId },
{
enabled: (options?.enabled ?? true) && itemId.length > 0,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.errorBookDetail ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
// ===== Mutation Hooks@contract-pending MSW 兜底,迁移自 CICD actions.ts=====
//
// portal-shell 使用 Apollo ClientCICD 使用 Server Actions。
// 以下 hooks 等价封装 CICD actions.ts 的 5 个 mutation 操作。
import { useWidgetMutation } from "../useWidgetMutation";
import {
CREATE_ERROR_BOOK_ITEM_DOC,
UPDATE_ERROR_BOOK_NOTE_DOC,
REVIEW_ERROR_BOOK_ITEM_DOC,
ARCHIVE_ERROR_BOOK_ITEM_DOC,
DELETE_ERROR_BOOK_ITEM_DOC,
} from "./operations/error-book-mutations.graphql";
import { ApiError } from "./errors";
/** 创建错题输入(对齐 CreateErrorBookItemInput */
export interface CreateErrorBookItemInput {
questionId: string;
studentAnswer?: unknown;
correctAnswer?: unknown;
subjectId?: string;
knowledgePointIds?: string[];
note?: string;
errorTags?: string[];
}
/** 更新笔记输入 */
export interface UpdateErrorBookNoteInput {
itemId: string;
note?: string;
errorTags?: string[];
}
/** 复习结果输入 */
export interface ReviewErrorBookItemInput {
itemId: string;
result: "again" | "hard" | "good" | "easy";
}
/** 复习结果响应 */
export interface ReviewErrorBookItemResponse {
itemId: string;
newInterval: number;
newMasteryLevel: number;
newStatus: string;
nextReviewAt: string;
}
interface CreateErrorBookItemResponse {
createErrorBookItem: { itemId: string } | null;
}
interface UpdateErrorBookNoteResponse {
updateErrorBookNote: { itemId: string } | null;
}
interface ReviewErrorBookItemMutationResponse {
reviewErrorBookItem: ReviewErrorBookItemResponse | null;
}
interface ArchiveErrorBookItemResponse {
archiveErrorBookItem: { itemId: string } | null;
}
interface DeleteErrorBookItemResponse {
deleteErrorBookItem: { itemId: string } | null;
}
/** ActionState 兼容返回类型(对齐 CICD ActionState */
export interface ActionState<T> {
success: boolean;
data?: T;
message?: string;
}
/**
* 创建错题手动添加mutation@contract-pending MSW 兜底)。
*/
export function useAddErrorBook(): {
run: (input: CreateErrorBookItemInput) => Promise<ActionState<string>>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
CreateErrorBookItemResponse,
{ input: CreateErrorBookItemInput }
>(CREATE_ERROR_BOOK_ITEM_DOC);
const run = async (
input: CreateErrorBookItemInput,
): Promise<ActionState<string>> => {
try {
const data = await rawRun({ input });
if (!data?.createErrorBookItem) {
throw new ApiError("Failed to add error book item", "INTERNAL_ERROR");
}
return {
success: true,
data: data.createErrorBookItem.itemId,
message: "错题已添加",
};
} catch (e) {
const message = e instanceof Error ? e.message : "添加错题失败";
return { success: false, message };
}
};
return { run, loading, error };
}
/**
* 更新错题笔记 mutation@contract-pending MSW 兜底)。
*/
export function useUpdateErrorBookNote(): {
run: (input: UpdateErrorBookNoteInput) => Promise<ActionState<void>>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateErrorBookNoteResponse,
{ input: UpdateErrorBookNoteInput }
>(UPDATE_ERROR_BOOK_NOTE_DOC);
const run = async (
input: UpdateErrorBookNoteInput,
): Promise<ActionState<void>> => {
try {
const data = await rawRun({ input });
if (!data?.updateErrorBookNote) {
throw new ApiError("Failed to update note", "INTERNAL_ERROR");
}
return { success: true, message: "笔记已更新" };
} catch (e) {
const message = e instanceof Error ? e.message : "更新笔记失败";
return { success: false, message };
}
};
return { run, loading, error };
}
/**
* 记录复习结果 mutation@contract-pending MSW 兜底)。
*/
export function useReviewErrorBook(): {
run: (
input: ReviewErrorBookItemInput,
) => Promise<ActionState<ReviewErrorBookItemResponse>>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
ReviewErrorBookItemMutationResponse,
{ input: ReviewErrorBookItemInput }
>(REVIEW_ERROR_BOOK_ITEM_DOC);
const run = async (
input: ReviewErrorBookItemInput,
): Promise<ActionState<ReviewErrorBookItemResponse>> => {
try {
const data = await rawRun({ input });
if (!data?.reviewErrorBookItem) {
throw new ApiError("Failed to record review", "INTERNAL_ERROR");
}
return {
success: true,
data: data.reviewErrorBookItem,
message: "复习结果已记录",
};
} catch (e) {
const message = e instanceof Error ? e.message : "记录复习结果失败";
return { success: false, message };
}
};
return { run, loading, error };
}
/**
* 归档错题 mutation@contract-pending MSW 兜底)。
*/
export function useArchiveErrorBook(): {
run: (itemId: string) => Promise<ActionState<void>>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<ArchiveErrorBookItemResponse, { itemId: string }>(
ARCHIVE_ERROR_BOOK_ITEM_DOC,
);
const run = async (itemId: string): Promise<ActionState<void>> => {
try {
const data = await rawRun({ itemId });
if (!data?.archiveErrorBookItem) {
throw new ApiError("Failed to archive item", "INTERNAL_ERROR");
}
return { success: true, message: "错题已归档" };
} catch (e) {
const message = e instanceof Error ? e.message : "归档错题失败";
return { success: false, message };
}
};
return { run, loading, error };
}
/**
* 删除错题 mutation@contract-pending MSW 兜底)。
*/
export function useDeleteErrorBook(): {
run: (itemId: string) => Promise<ActionState<void>>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DeleteErrorBookItemResponse, { itemId: string }>(
DELETE_ERROR_BOOK_ITEM_DOC,
);
const run = async (itemId: string): Promise<ActionState<void>> => {
try {
const data = await rawRun({ itemId });
if (!data?.deleteErrorBookItem) {
throw new ApiError("Failed to delete item", "INTERNAL_ERROR");
}
return { success: true, message: "错题已删除" };
} catch (e) {
const message = e instanceof Error ? e.message : "删除错题失败";
return { success: false, message };
}
};
return { run, loading, error };
}

View File

@@ -20,6 +20,8 @@ import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors"; import { ApiError } from "./errors";
import { import {
CREATE_EXAM_DOC, CREATE_EXAM_DOC,
DELETE_EXAM_DOC,
DUPLICATE_EXAM_DOC,
GET_EXAM_ANALYTICS_DOC, GET_EXAM_ANALYTICS_DOC,
GET_EXAM_BUILD_DOC, GET_EXAM_BUILD_DOC,
GET_EXAM_DOC, GET_EXAM_DOC,
@@ -28,6 +30,7 @@ import {
GET_QUESTIONS_LIBRARY_DOC, GET_QUESTIONS_LIBRARY_DOC,
SAVE_EXAM_BUILD_DOC, SAVE_EXAM_BUILD_DOC,
SAVE_EXAM_RICH_CONTENT_DOC, SAVE_EXAM_RICH_CONTENT_DOC,
UPDATE_EXAM_STATUS_DOC,
} from "./operations/exams.graphql"; } from "./operations/exams.graphql";
import type { UseQueryResult } from "./types"; import type { UseQueryResult } from "./types";
@@ -542,3 +545,118 @@ export function useSaveExamRichContent(): {
return { run, loading, error }; return { run, loading, error };
} }
// ===== Delete / UpdateStatus / Duplicate Hooks@contract-pending=====
/** 删除考试响应 */
interface DeleteExamResponse {
deleteExam: { id: string } | null;
}
/** 更新考试状态响应 */
interface UpdateExamStatusResponse {
updateExamStatus: { id: string; status: string } | null;
}
/** 复制考试响应 */
interface DuplicateExamResponse {
duplicateExam: { id: string } | null;
}
/**
* 删除考试(@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useDeleteExam(): {
run: (id: string) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DeleteExamResponse, { id: string }>(DELETE_EXAM_DOC);
const run = async (id: string): Promise<{ id: string }> => {
const data = await rawRun({ id });
if (!data?.deleteExam) {
throw new ApiError("Failed to delete exam", "INTERNAL_ERROR");
}
return data.deleteExam;
};
return { run, loading, error };
}
/**
* 更新考试状态(@contract-pendingMSW 兜底)。
*
* 用于发布/归档/恢复草稿等状态流转。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useUpdateExamStatus(): {
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateExamStatusResponse,
{ id: string; status: string }
>(UPDATE_EXAM_STATUS_DOC);
const run = async (
id: string,
status: string,
): Promise<{ id: string; status: string }> => {
const data = await rawRun({ id, status });
if (!data?.updateExamStatus) {
throw new ApiError("Failed to update exam status", "INTERNAL_ERROR");
}
return data.updateExamStatus;
};
return { run, loading, error };
}
/**
* 复制考试(@contract-pendingMSW 兜底)。
*
* 复制考试基本信息生成新考试(不含题目结构)。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useDuplicateExam(): {
run: (id: string) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DuplicateExamResponse, { id: string }>(
DUPLICATE_EXAM_DOC,
);
const run = async (id: string): Promise<{ id: string }> => {
const data = await rawRun({ id });
if (!data?.duplicateExam) {
throw new ApiError("Failed to duplicate exam", "INTERNAL_ERROR");
}
return data.duplicateExam;
};
return { run, loading, error };
}

View File

@@ -22,12 +22,25 @@ import { useWidgetMutation } from "../useWidgetMutation";
import { useWidgetQuery } from "../useWidgetQuery"; import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors"; import { ApiError } from "./errors";
import { import {
ACQUIRE_DRAFT_LOCK_DOC,
BATCH_CREATE_GRADE_RECORDS_BY_EXAM_DOC,
BULK_DELETE_GRADES_DOC,
CREATE_GRADE_DOC, CREATE_GRADE_DOC,
DELETE_GRADE_RECORD_DOC,
DOWNLOAD_GRADE_IMPORT_TEMPLATE_DOC,
GET_DRAFT_LOCK_DOC,
GET_EXAM_FOR_GRADE_ENTRY_DOC,
GET_EXAM_OPTIONS_FOR_ENTRY_DOC,
GET_GRADE_ANALYTICS_DOC, GET_GRADE_ANALYTICS_DOC,
GET_GRADE_DOC, GET_GRADE_DOC,
GET_GRADES_LIST_DOC, GET_GRADES_LIST_DOC,
GET_GRADE_STATS_DOC, GET_GRADE_STATS_DOC,
GET_REPORT_CARD_DOC, GET_REPORT_CARD_DOC,
GET_SCHOOL_WIDE_GRADE_SUMMARY_DOC,
IMPORT_GRADES_FROM_EXCEL_DOC,
RELEASE_DRAFT_LOCK_DOC,
UNDO_GRADE_ENTRY_DOC,
UPDATE_GRADE_RECORD_DOC,
} from "./operations/grades.graphql"; } from "./operations/grades.graphql";
import type { UseQueryResult } from "./types"; import type { UseQueryResult } from "./types";
@@ -424,3 +437,647 @@ export function useReportCard(
refetch: result.refetch, refetch: result.refetch,
}; };
} }
// ===== 更新/删除/批量删除(@contract-pending 全 MSW=====
/** 更新成绩输入 */
export interface UpdateGradeInput {
title?: string;
score?: number;
totalScore?: number;
type?: string;
semester?: string;
remark?: string | null;
}
/** UpdateGrade mutation 响应 */
interface UpdateGradeResponse {
updateGrade: { gradeId: string } | null;
}
/** DeleteGrade mutation 响应 */
interface DeleteGradeResponse {
deleteGrade: { gradeId: string } | null;
}
/** BulkDeleteGrades mutation 响应 */
interface BulkDeleteGradesResponse {
bulkDeleteGrades: { count: number } | null;
}
/**
* 更新成绩记录 mutation@contract-pendingMSW 兜底)。
*
* 注:命名为 useUpdateGradeRecord 以区分 admin-p5.ts 中的 useUpdateGrade
* (后者用于年级 Grade 实体管理,非成绩记录)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useUpdateGradeRecord(): {
run: (id: string, input: UpdateGradeInput) => Promise<{ gradeId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateGradeResponse,
{ id: string; input: UpdateGradeInput }
>(UPDATE_GRADE_RECORD_DOC);
const run = async (
id: string,
input: UpdateGradeInput,
): Promise<{ gradeId: string }> => {
const data = await rawRun({ id, input });
if (!data?.updateGrade) {
throw new ApiError("Failed to update grade", "INTERNAL_ERROR");
}
return data.updateGrade;
};
return { run, loading, error };
}
/**
* 删除成绩记录 mutation@contract-pendingMSW 兜底)。
*
* 注:命名为 useDeleteGradeRecord 以区分 admin-p5.ts 中的 useDeleteGrade
* (后者用于年级 Grade 实体管理,非成绩记录)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useDeleteGradeRecord(): {
run: (id: string) => Promise<{ gradeId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DeleteGradeResponse, { id: string }>(
DELETE_GRADE_RECORD_DOC,
);
const run = async (id: string): Promise<{ gradeId: string }> => {
const data = await rawRun({ id });
if (!data?.deleteGrade) {
throw new ApiError("Failed to delete grade", "INTERNAL_ERROR");
}
return data.deleteGrade;
};
return { run, loading, error };
}
/**
* 批量删除成绩 mutation@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useBulkDeleteGrades(): {
run: (ids: string[]) => Promise<{ count: number }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<BulkDeleteGradesResponse, { ids: string[] }>(
BULK_DELETE_GRADES_DOC,
);
const run = async (ids: string[]): Promise<{ count: number }> => {
const data = await rawRun({ ids });
if (!data?.bulkDeleteGrades) {
throw new ApiError("Failed to bulk delete grades", "INTERNAL_ERROR");
}
return data.bulkDeleteGrades;
};
return { run, loading, error };
}
// ===== 按试卷批量录入(@contract-pending 全 MSW=====
/** 批量录入单条记录输入 */
export interface BatchGradeRecordInput {
studentId: string;
answers: Array<{ questionId: string; score: number }>;
remark?: string;
}
/** BatchCreateGradeRecordsByExam mutation 响应 */
interface BatchCreateGradeRecordsByExamResponse {
batchCreateGradeRecordsByExam: { gradeIds: string[] } | null;
}
/** UndoGradeEntry mutation 响应 */
interface UndoGradeEntryResponse {
undoGradeEntry: { count: number } | null;
}
/**
* 按试卷批量录入成绩 mutation@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useBatchCreateGradeRecordsByExam(): {
run: (
examId: string,
classId: string,
records: BatchGradeRecordInput[],
) => Promise<{ gradeIds: string[] }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
BatchCreateGradeRecordsByExamResponse,
{
examId: string;
classId: string;
records: BatchGradeRecordInput[];
}
>(BATCH_CREATE_GRADE_RECORDS_BY_EXAM_DOC);
const run = async (
examId: string,
classId: string,
records: BatchGradeRecordInput[],
): Promise<{ gradeIds: string[] }> => {
const data = await rawRun({ examId, classId, records });
if (!data?.batchCreateGradeRecordsByExam) {
throw new ApiError("Failed to batch create grades", "INTERNAL_ERROR");
}
return data.batchCreateGradeRecordsByExam;
};
return { run, loading, error };
}
/**
* 撤销成绩录入 mutation@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useUndoGradeEntry(): {
run: (gradeIds: string[]) => Promise<{ count: number }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<UndoGradeEntryResponse, { gradeIds: string[] }>(
UNDO_GRADE_ENTRY_DOC,
);
const run = async (gradeIds: string[]): Promise<{ count: number }> => {
const data = await rawRun({ gradeIds });
if (!data?.undoGradeEntry) {
throw new ApiError("Failed to undo grade entry", "INTERNAL_ERROR");
}
return data.undoGradeEntry;
};
return { run, loading, error };
}
// ===== Excel 导入/模板下载(@contract-pending 全 MSW=====
/** Excel 导入无效行 */
export interface GradeImportInvalidRow {
row: number;
studentName: string;
score: string;
errors: string[];
}
/** Excel 导入结果 */
export interface GradeImportResult {
success: boolean;
totalRows: number;
successCount: number;
failedCount: number;
unmatchedStudents: string[];
invalidRows: GradeImportInvalidRow[];
}
/** 模板下载响应 */
interface DownloadGradeImportTemplateResponse {
downloadGradeImportTemplate: { buffer: string; filename: string } | null;
}
/** Excel 导入响应 */
interface ImportGradesFromExcelResponse {
importGradesFromExcel: GradeImportResult | null;
}
/** 模板下载结果 */
export interface GradeImportTemplate {
buffer: string;
filename: string;
}
/**
* 下载成绩导入模板(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useDownloadGradeImportTemplate(): {
run: (classId: string) => Promise<GradeImportTemplate>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
DownloadGradeImportTemplateResponse,
{ classId: string }
>(DOWNLOAD_GRADE_IMPORT_TEMPLATE_DOC);
const run = async (classId: string): Promise<GradeImportTemplate> => {
const data = await rawRun({ classId });
if (!data?.downloadGradeImportTemplate) {
throw new ApiError("Failed to download template", "INTERNAL_ERROR");
}
return data.downloadGradeImportTemplate;
};
return { run, loading, error };
}
/** Excel 导入输入 */
export interface ImportGradesFromExcelInput {
file: string;
classId: string;
subjectId: string;
title: string;
examId?: string;
fullScore?: number;
type?: string;
semester?: string;
}
/**
* 从 Excel 导入成绩(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useImportGradesFromExcel(): {
run: (input: ImportGradesFromExcelInput) => Promise<GradeImportResult>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
ImportGradesFromExcelResponse,
ImportGradesFromExcelInput
>(IMPORT_GRADES_FROM_EXCEL_DOC);
const run = async (
input: ImportGradesFromExcelInput,
): Promise<GradeImportResult> => {
const data = await rawRun(input);
if (!data?.importGradesFromExcel) {
throw new ApiError("Failed to import grades", "INTERNAL_ERROR");
}
return data.importGradesFromExcel;
};
return { run, loading, error };
}
// ===== 成绩录入用考试/学生查询(@contract-pending 全 MSW=====
/** 成绩录入用考试选项 */
export interface ExamOptionForEntry {
id: string;
title: string;
subjectName: string;
questionCount: number;
totalScore: number;
classId: string;
gradeId?: string;
}
/** 批量录入试卷题目 */
export interface ExamForGradeEntryQuestion {
id: string;
title: string;
score: number;
}
/** 批量录入试卷学生 */
export interface ExamForGradeEntryStudent {
id: string;
name: string;
studentNo: string;
}
/** 批量录入用试卷实体 */
export interface ExamForGradeEntry {
id: string;
title: string;
fullScore: number;
classId: string;
gradeId?: string;
questions: ExamForGradeEntryQuestion[];
students: ExamForGradeEntryStudent[];
}
/** ExamOptionsForEntry 查询响应 */
interface ExamOptionsForEntryResponse {
examOptionsForEntry: ExamOptionForEntry[];
}
/** ExamForGradeEntry 查询响应 */
interface ExamForGradeEntryResponse {
examForGradeEntry: ExamForGradeEntry | null;
}
/**
* 查询成绩录入用考试选项列表(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useExamOptionsForEntry(
options?: GradeQueryOptions,
): UseQueryResult<ExamOptionForEntry[]> {
const result = useWidgetQuery<
ExamOptionsForEntryResponse,
Record<string, never>
>(
GET_EXAM_OPTIONS_FOR_ENTRY_DOC,
{},
{
...options,
enabled: options?.enabled ?? true,
},
);
return {
data: result.data?.examOptionsForEntry ?? [],
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询批量录入用试卷详情(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useExamForGradeEntry(
examId: string,
options?: GradeQueryOptions,
): UseQueryResult<ExamForGradeEntry | null> {
const result = useWidgetQuery<ExamForGradeEntryResponse, { examId: string }>(
GET_EXAM_FOR_GRADE_ENTRY_DOC,
{ examId },
{
...options,
enabled: options?.enabled ?? examId.length > 0,
},
);
return {
data: result.data?.examForGradeEntry ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
// ===== 草稿锁(@contract-pending 全 MSW=====
/** 草稿锁状态 */
export interface DraftLockStatus {
isLocked: boolean;
lockedBy: string | null;
lockedByName: string | null;
lockedAt: string | null;
isMine: boolean;
}
/** DraftLock 查询响应 */
interface DraftLockResponse {
draftLock: DraftLockStatus | null;
}
/** AcquireDraftLock mutation 响应 */
interface AcquireDraftLockResponse {
acquireDraftLock: DraftLockStatus | null;
}
/** ReleaseDraftLock mutation 响应 */
interface ReleaseDraftLockResponse {
releaseDraftLock: DraftLockStatus | null;
}
/**
* 查询草稿锁状态(@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useDraftLock(
scope: string,
options?: GradeQueryOptions,
): UseQueryResult<DraftLockStatus | null> {
const result = useWidgetQuery<DraftLockResponse, { scope: string }>(
GET_DRAFT_LOCK_DOC,
{ scope },
{
...options,
enabled: options?.enabled ?? scope.length > 0,
},
);
return {
data: result.data?.draftLock ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 获取草稿锁 mutation@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useAcquireDraftLock(): {
run: (scope: string) => Promise<DraftLockStatus>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<AcquireDraftLockResponse, { scope: string }>(
ACQUIRE_DRAFT_LOCK_DOC,
);
const run = async (scope: string): Promise<DraftLockStatus> => {
const data = await rawRun({ scope });
if (!data?.acquireDraftLock) {
throw new ApiError("Failed to acquire draft lock", "INTERNAL_ERROR");
}
return data.acquireDraftLock;
};
return { run, loading, error };
}
/**
* 释放草稿锁 mutation@contract-pendingMSW 兜底)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4
*/
export function useReleaseDraftLock(): {
run: (scope: string) => Promise<DraftLockStatus>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<ReleaseDraftLockResponse, { scope: string }>(
RELEASE_DRAFT_LOCK_DOC,
);
const run = async (scope: string): Promise<DraftLockStatus> => {
const data = await rawRun({ scope });
if (!data?.releaseDraftLock) {
throw new ApiError("Failed to release draft lock", "INTERNAL_ERROR");
}
return data.releaseDraftLock;
};
return { run, loading, error };
}
// ===== 全校年级成绩洞察(@contract-pending 全 MSW=====
/** 全校年级成绩汇总统计 */
export interface SchoolWideOverallStats {
averageScore: number;
passRate: number;
excellenceRate: number;
totalParticipants: number;
/** 全校班级总数(@contract-pendingMSW 兜底) */
classes?: number;
/** 全校学生总数(@contract-pendingMSW 兜底) */
students?: number;
/** 整体平均分(@contract-pendingMSW 兜底) */
overallAvg?: number;
/** 最新一次作业平均分(@contract-pendingMSW 兜底) */
latestAvg?: number;
}
/** 班级排名项(年级洞察页 classRankings */
export interface GradeInsightsClassRanking {
classId: string;
className: string;
averageScore: number;
passRate: number;
studentCount: number;
rank: number;
delta: number;
/** 上次平均分(@contract-pendingMSW 兜底) */
prevAvg?: number;
/** 整体平均分(@contract-pendingMSW 兜底) */
overallAvg?: number;
}
/** 年级洞察项(年级洞察页 grades[] */
export interface GradeInsightsGrade {
gradeId: string;
gradeName: string;
averageScore: number;
passRate: number;
excellenceRate: number;
participantCount: number;
classRankings: GradeInsightsClassRanking[];
}
/** 最近作业项(年级洞察页 recentAssignments[] */
export interface GradeInsightsAssignment {
assignmentId: string;
title: string;
subjectName: string;
gradeName: string;
averageScore: number;
submitCount: number;
totalStudents: number;
status: string;
createdAt: string;
/** 应交人数(@contract-pendingMSW 兜底) */
targeted?: number;
/** 已批改人数(@contract-pendingMSW 兜底) */
graded?: number;
/** 中位数(@contract-pendingMSW 兜底) */
median?: number;
}
/** 全校年级成绩洞察聚合结果 */
export interface SchoolWideGradeInsights {
overallStats: SchoolWideOverallStats;
grades: GradeInsightsGrade[];
recentAssignments: GradeInsightsAssignment[];
}
/** SchoolWideGradeSummary 查询响应 */
interface SchoolWideGradeInsightsResponse {
schoolWideGradeSummary: SchoolWideGradeInsights | null;
}
/**
* 查询全校年级成绩洞察(@contract-pendingMSW 兜底)。
*
* 用于 /shell/admin/school/grades/insights 年级洞察页:
* - overallStats全校平均分、及格率、优秀率、参考人数
* - grades[]:各年级统计 + 班级排名
* - recentAssignments[]:最近作业列表
*
* schema 无 schoolWideGradeSummary 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.4 管理域 / §11.4 契约工单
*/
export function useSchoolWideGradeSummary(
gradeId?: string,
options?: GradeQueryOptions,
): UseQueryResult<SchoolWideGradeInsights | null> {
const result = useWidgetQuery<
SchoolWideGradeInsightsResponse,
{ gradeId?: string }
>(
GET_SCHOOL_WIDE_GRADE_SUMMARY_DOC,
{ gradeId },
{
...options,
enabled: options?.enabled ?? true,
},
);
return {
data: result.data?.schoolWideGradeSummary ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -19,15 +19,21 @@ import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors"; import { ApiError } from "./errors";
import { import {
ASSIGN_HOMEWORK_DOC, ASSIGN_HOMEWORK_DOC,
BATCH_AUTO_GRADE_DOC,
DELETE_SCAN_DOC,
GET_AI_BATCH_GRADING_DOC, GET_AI_BATCH_GRADING_DOC,
GET_ASSIGNMENT_SUBMISSIONS_DOC, GET_ASSIGNMENT_SUBMISSIONS_DOC,
GET_HOMEWORK_DOC, GET_HOMEWORK_DOC,
GET_HOMEWORK_LIST_DOC, GET_HOMEWORK_LIST_DOC,
GET_HOMEWORK_SUBMISSIONS_DOC, GET_HOMEWORK_SUBMISSIONS_DOC,
GET_SCANS_DOC,
GET_SUBMISSION_DETAIL_DOC, GET_SUBMISSION_DETAIL_DOC,
GRADE_SUBMISSION_DOC, GRADE_SUBMISSION_DOC,
RECORD_GRADE_DOC, RECORD_GRADE_DOC,
SAVE_HOMEWORK_ANSWER_DOC,
SAVE_SCAN_GRADING_DOC, SAVE_SCAN_GRADING_DOC,
START_HOMEWORK_SUBMISSION_DOC,
SUBMIT_HOMEWORK_DOC,
} from "./operations/homework.graphql"; } from "./operations/homework.graphql";
import type { UseQueryResult } from "./types"; import type { UseQueryResult } from "./types";
@@ -610,3 +616,295 @@ export function useRecordGrade(): {
return { run, loading, error }; return { run, loading, error };
} }
// ===== 学生作答相关 hooks@contract-pending 全 MSW=====
// 用于 /shell/teacher/homework/[id]/take 学生作答页
// 关联ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
/** 扫描图(与 ScanImage 对齐,供组件直接消费) */
export interface HomeworkScan {
fileId: string;
url: string;
filename: string;
originalName?: string;
page: number;
}
/** 开始作答输入 */
export interface StartHomeworkSubmissionInput {
assignmentId: string;
}
/** 开始作答响应 */
interface StartHomeworkSubmissionResponse {
startHomeworkSubmission: { submissionId: string } | null;
}
/** 保存单题答案输入 */
export interface SaveHomeworkAnswerInput {
submissionId: string;
questionId: string;
answerJson: string;
}
/** 保存单题答案响应 */
interface SaveHomeworkAnswerResponse {
saveHomeworkAnswer: { submissionId: string; questionId: string } | null;
}
/** 提交作业输入 */
export interface SubmitHomeworkInput {
submissionId: string;
}
/** 提交作业响应 */
interface SubmitHomeworkResponse {
submitHomework: { submissionId: string; totalScore: number | null } | null;
}
/** 扫描图查询响应 */
interface GetScansResponse {
scans: HomeworkScan[];
}
/** 删除扫描图输入 */
export interface DeleteScanInput {
submissionId: string;
fileId: string;
}
/** 删除扫描图响应 */
interface DeleteScanResponse {
deleteScan: { fileId: string; success: boolean } | null;
}
/** 批量自动批改输入 */
export interface BatchAutoGradeInput {
submissionIds: string[];
}
/** 批量自动批改响应 */
interface BatchAutoGradeResponse {
batchAutoGrade: {
processedCount: number;
successCount: number;
failedCount: number;
} | null;
}
/**
* 开始作答 mutation@contract-pendingMSW 兜底)。
*
* 学生进入作答页点击"开始作答"时调用,创建一条 started 状态的提交。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
*/
export function useStartHomeworkSubmission(): {
run: (
input: StartHomeworkSubmissionInput,
) => Promise<{ submissionId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
StartHomeworkSubmissionResponse,
{ input: StartHomeworkSubmissionInput }
>(START_HOMEWORK_SUBMISSION_DOC);
const run = async (
input: StartHomeworkSubmissionInput,
): Promise<{ submissionId: string }> => {
const data = await rawRun({ input });
if (!data?.startHomeworkSubmission) {
throw new ApiError(
"Failed to start homework submission",
"INTERNAL_ERROR",
);
}
return data.startHomeworkSubmission;
};
return { run, loading, error };
}
/**
* 保存单题答案 mutation@contract-pendingMSW 兜底)。
*
* 学生作答过程中debounced保存每题答案。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
*/
export function useSaveHomeworkAnswer(): {
run: (
input: SaveHomeworkAnswerInput,
) => Promise<{ submissionId: string; questionId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
SaveHomeworkAnswerResponse,
{ input: SaveHomeworkAnswerInput }
>(SAVE_HOMEWORK_ANSWER_DOC);
const run = async (
input: SaveHomeworkAnswerInput,
): Promise<{ submissionId: string; questionId: string }> => {
const data = await rawRun({ input });
if (!data?.saveHomeworkAnswer) {
throw new ApiError("Failed to save homework answer", "INTERNAL_ERROR");
}
return data.saveHomeworkAnswer;
};
return { run, loading, error };
}
/**
* 提交作业 mutation@contract-pendingMSW 兜底)。
*
* 学生点击"提交全部"后锁定提交并触发服务端自动判分。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
*/
export function useSubmitHomework(): {
run: (
input: SubmitHomeworkInput,
) => Promise<{ submissionId: string; totalScore: number | null }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<SubmitHomeworkResponse, { input: SubmitHomeworkInput }>(
SUBMIT_HOMEWORK_DOC,
);
const run = async (
input: SubmitHomeworkInput,
): Promise<{ submissionId: string; totalScore: number | null }> => {
const data = await rawRun({ input });
if (!data?.submitHomework) {
throw new ApiError("Failed to submit homework", "INTERNAL_ERROR");
}
return data.submitHomework;
};
return { run, loading, error };
}
/**
* 查询某次提交的答题扫描图(@contract-pendingMSW 兜底)。
*
* schema 无 scans 根字段,由 MSW handlers 返回 mock 数据。
* submissionId 为 null/空时禁用查询。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
*/
export function useGetScans(
submissionId: string | null,
options?: HomeworkQueryOptions,
): UseQueryResult<HomeworkScan[]> {
const result = useWidgetQuery<GetScansResponse, { submissionId: string }>(
GET_SCANS_DOC,
{ submissionId: submissionId ?? "" },
{
...options,
enabled:
(options?.enabled ?? true) &&
submissionId !== null &&
submissionId.length > 0,
},
);
return {
data: result.data?.scans,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 删除扫描图 mutation@contract-pendingMSW 兜底)。
*
* 学生在作答页删除已上传的扫描图。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
*/
export function useDeleteScan(): {
run: (
input: DeleteScanInput,
) => Promise<{ fileId: string; success: boolean }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DeleteScanResponse, { input: DeleteScanInput }>(
DELETE_SCAN_DOC,
);
const run = async (
input: DeleteScanInput,
): Promise<{ fileId: string; success: boolean }> => {
const data = await rawRun({ input });
if (!data?.deleteScan) {
throw new ApiError("Failed to delete scan", "INTERNAL_ERROR");
}
return data.deleteScan;
};
return { run, loading, error };
}
/**
* 批量自动批改 mutation@contract-pendingMSW 兜底)。
*
* 教师在按作业批量批改页一键自动批改多份提交的客观题。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 批量批改页 / §11.4 契约工单
*/
export function useBatchAutoGrade(): {
run: (input: BatchAutoGradeInput) => Promise<{
processedCount: number;
successCount: number;
failedCount: number;
}>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<BatchAutoGradeResponse, { input: BatchAutoGradeInput }>(
BATCH_AUTO_GRADE_DOC,
);
const run = async (
input: BatchAutoGradeInput,
): Promise<{
processedCount: number;
successCount: number;
failedCount: number;
}> => {
const data = await rawRun({ input });
if (!data?.batchAutoGrade) {
throw new ApiError("Failed to batch auto grade", "INTERNAL_ERROR");
}
return data.batchAutoGrade;
};
return { run, loading, error };
}

Some files were not shown because too many files have changed in this diff Show More