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:
@@ -57,7 +57,10 @@ generates:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
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:
|
||||
preResolveTypes: true
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-hash": "^4.0.1",
|
||||
@@ -48,6 +55,9 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^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",
|
||||
"swr": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { StudentReportCardClient } from "@/features/student/grades/report-card-client";
|
||||
import "@/features/student/grades/report-card-print.css";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,116 +1,24 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
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";
|
||||
import { StudentDashboardClient } from "@/features/student/dashboard/dashboard-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 学生仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
||||
* 学生仪表盘首页(ARCHITECTURE.md §7.1 仪表盘 / §9.2 / §10 P1-2 / P3)
|
||||
*
|
||||
* 改接 data-ana 的 studentDashboard 真实聚合查询,替换原
|
||||
* grades/homeworks/schedule/exams 假契约 widget 查询。
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 StudentDashboardClient(client 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 {
|
||||
const t = useTranslations("studentDomain.dashboard.home");
|
||||
const { data, loading, error } = useStudentDashboard();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell title={t("title")} description={t("description")}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<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>
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<StudentDashboardClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
|
||||
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 { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
||||
* 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2 / §10 P2)
|
||||
*
|
||||
* 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原
|
||||
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。
|
||||
* P1-2:data-ana teacherDashboard 真实聚合查询(stats / classes / warnings)
|
||||
* P2:迁移 CICD 教师仪表盘 7 张卡片组件(todo / schedule / homework / classes /
|
||||
* gradeTrends / recentSubmissions / quickActions)。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2 / §11.3 DoD #6(i18n)
|
||||
* 数据契约(@contract-pending):
|
||||
* - 富字段(assignments / submissions / gradeTrends / todayScheduleItems)
|
||||
* schema 未就绪 → 暂传空数组占位,待 data-ana subgraph 补齐后接入。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2 / §10 P2 / §11.3 DoD #6(i18n)
|
||||
*/
|
||||
export default function TeacherDashboardPage(): React.ReactElement {
|
||||
const t = useTranslations("dashboard.teacher");
|
||||
const tDashboard = useTranslations("dashboard");
|
||||
const { data, loading, error } = useTeacherDashboard();
|
||||
|
||||
// P2 迁移:根据当前时间生成问候语 key(morning/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) {
|
||||
return (
|
||||
<DashboardShell title={t("title")} description={t("description")}>
|
||||
@@ -47,7 +81,7 @@ export default function TeacherDashboardPage(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title={t("title")}
|
||||
title={greeting}
|
||||
description={t("description")}
|
||||
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">
|
||||
{data.classes ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -59,8 +59,8 @@ describe("isValidProviderType", () => {
|
||||
});
|
||||
|
||||
describe("activeToBadgeClass", () => {
|
||||
it("returns emerald class for active", () => {
|
||||
expect(activeToBadgeClass(true)).toContain("emerald");
|
||||
it("returns success class for active", () => {
|
||||
expect(activeToBadgeClass(true)).toContain("success");
|
||||
});
|
||||
|
||||
it("returns muted class for inactive", () => {
|
||||
|
||||
@@ -369,7 +369,7 @@ export function AiProviderForm({
|
||||
<span
|
||||
className={
|
||||
testResult.ok
|
||||
? "text-sm text-emerald-600 dark:text-emerald-400"
|
||||
? "text-sm text-success"
|
||||
: "text-sm text-destructive"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function formatProviderVisibility(visibility: string): string {
|
||||
*/
|
||||
export function activeToBadgeClass(isActive: boolean): string {
|
||||
return isActive
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import { Textarea } from "@/shared/components/ui/textarea";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
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="space-y-2">
|
||||
<Label htmlFor="ann-status">{t("fieldStatus")}</Label>
|
||||
<select
|
||||
<Select
|
||||
id="ann-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"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`statusOption_${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ann-audience">{t("fieldAudience")}</Label>
|
||||
<select
|
||||
<Select
|
||||
id="ann-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"
|
||||
>
|
||||
{AUDIENCE_OPTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{t(`audienceOption_${a}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -188,17 +189,19 @@ export function AnnouncementsListClient(): React.ReactElement {
|
||||
filters={
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("filterStatus")}</span>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
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>
|
||||
}
|
||||
loading={loading}
|
||||
|
||||
@@ -113,24 +113,24 @@ describe("attendanceStatusToKey", () => {
|
||||
});
|
||||
|
||||
describe("attendanceStatusToBadgeClass", () => {
|
||||
it("returns emerald for present", () => {
|
||||
expect(attendanceStatusToBadgeClass("present")).toContain("emerald");
|
||||
it("returns success for present", () => {
|
||||
expect(attendanceStatusToBadgeClass("present")).toContain("success");
|
||||
});
|
||||
|
||||
it("returns destructive for absent", () => {
|
||||
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
|
||||
});
|
||||
|
||||
it("returns amber for late", () => {
|
||||
expect(attendanceStatusToBadgeClass("late")).toContain("amber");
|
||||
it("returns warning for late", () => {
|
||||
expect(attendanceStatusToBadgeClass("late")).toContain("warning");
|
||||
});
|
||||
|
||||
it("returns sky for leave", () => {
|
||||
expect(attendanceStatusToBadgeClass("leave")).toContain("sky");
|
||||
expect(attendanceStatusToBadgeClass("leave")).toContain("info");
|
||||
});
|
||||
|
||||
it("falls back to emerald (present) for unknown", () => {
|
||||
expect(attendanceStatusToBadgeClass("unknown")).toContain("emerald");
|
||||
it("falls back to success (present) for unknown", () => {
|
||||
expect(attendanceStatusToBadgeClass("unknown")).toContain("success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,14 +152,14 @@ describe("formatRate", () => {
|
||||
});
|
||||
|
||||
describe("presentRateToColorClass", () => {
|
||||
it("returns emerald for rate >= 0.95", () => {
|
||||
expect(presentRateToColorClass(0.95)).toContain("emerald");
|
||||
expect(presentRateToColorClass(1)).toContain("emerald");
|
||||
it("returns success for rate >= 0.95", () => {
|
||||
expect(presentRateToColorClass(0.95)).toContain("success");
|
||||
expect(presentRateToColorClass(1)).toContain("success");
|
||||
});
|
||||
|
||||
it("returns amber for 0.9 <= rate < 0.95", () => {
|
||||
expect(presentRateToColorClass(0.9)).toContain("amber");
|
||||
expect(presentRateToColorClass(0.94)).toContain("amber");
|
||||
it("returns warning for 0.9 <= rate < 0.95", () => {
|
||||
expect(presentRateToColorClass(0.9)).toContain("warning");
|
||||
expect(presentRateToColorClass(0.94)).toContain("warning");
|
||||
});
|
||||
|
||||
it("returns destructive for rate < 0.9", () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useGrades,
|
||||
} from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import {
|
||||
ListPageShell,
|
||||
@@ -199,60 +200,57 @@ function AttendanceFilters({
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("gradeFilter")}
|
||||
</label>
|
||||
<select
|
||||
<Select
|
||||
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]"
|
||||
>
|
||||
<option value="">{t("allGrades")}</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("classFilter")}
|
||||
</label>
|
||||
<select
|
||||
<Select
|
||||
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]"
|
||||
>
|
||||
<option value="">{t("allClasses")}</option>
|
||||
{classes.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("statusFilter")}
|
||||
</label>
|
||||
<select
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
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("allStatuses")}</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(
|
||||
onValueChange={(v) => onStatusChange(v)}
|
||||
options={[
|
||||
{ value: "", label: t("allStatuses") },
|
||||
...STATUS_OPTIONS.map((s) => ({
|
||||
value: s,
|
||||
label: t(
|
||||
`status${s.charAt(0).toUpperCase()}${s.slice(1)}` as
|
||||
| "statusPresent"
|
||||
| "statusAbsent"
|
||||
| "statusLate"
|
||||
| "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 className="flex flex-col gap-1">
|
||||
@@ -324,12 +322,12 @@ function AttendanceContent({
|
||||
<StatCard
|
||||
title={t("statsLateRate")}
|
||||
value={formatRate(stats.lateRate)}
|
||||
valueClassName="text-amber-600 dark:text-amber-400"
|
||||
valueClassName="text-warning"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("statsEarlyLeaveRate")}
|
||||
value={formatRate(stats.earlyLeaveRate)}
|
||||
valueClassName="text-sky-600 dark:text-sky-400"
|
||||
valueClassName="text-info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,12 +182,12 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
|
||||
<SummaryStat
|
||||
label={t("summaryStrong")}
|
||||
value={String(summary.strong)}
|
||||
valueClassName="text-emerald-600 dark:text-emerald-400"
|
||||
valueClassName="text-success"
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("summaryMedium")}
|
||||
value={String(summary.medium)}
|
||||
valueClassName="text-amber-600 dark:text-amber-400"
|
||||
valueClassName="text-warning"
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("summaryWeak")}
|
||||
@@ -421,12 +421,10 @@ function renderTierBadge(
|
||||
let className: string;
|
||||
if (tier === "strong") {
|
||||
label = t("badgeStrong");
|
||||
className =
|
||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
|
||||
className = "bg-success/10 text-success border-success/30";
|
||||
} else if (tier === "medium") {
|
||||
label = t("badgeMedium");
|
||||
className =
|
||||
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
className = "bg-warning/10 text-warning border-warning/30";
|
||||
} else {
|
||||
label = t("badgeWeak");
|
||||
className = "bg-destructive/10 text-destructive border-destructive/30";
|
||||
|
||||
@@ -217,16 +217,16 @@ function formatPercent(rate: number | null | undefined): string {
|
||||
|
||||
/**
|
||||
* 根据出勤率(0-1)返回 Tailwind 文本语义类名。
|
||||
* - >= 0.95 → emerald(优秀)
|
||||
* - >= 0.9 → amber(一般)
|
||||
* - >= 0.95 → success(优秀)
|
||||
* - >= 0.9 → warning(一般)
|
||||
* - 其他 → destructive(低出勤率)
|
||||
*/
|
||||
function rateToColorClass(rate: number | null | undefined): string {
|
||||
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (rate >= RATE_TIER_HIGH) return "text-emerald-600 dark:text-emerald-400";
|
||||
if (rate >= RATE_TIER_MID) return "text-amber-600 dark:text-amber-400";
|
||||
if (rate >= RATE_TIER_HIGH) return "text-success";
|
||||
if (rate >= RATE_TIER_MID) return "text-warning";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
@@ -241,12 +241,10 @@ function renderRateBadge(
|
||||
let className: string;
|
||||
if (rate >= RATE_TIER_HIGH) {
|
||||
label = t("badgeHigh");
|
||||
className =
|
||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
|
||||
className = "bg-success/10 text-success border-success/30";
|
||||
} else if (rate >= RATE_TIER_MID) {
|
||||
label = t("badgeMid");
|
||||
className =
|
||||
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
className = "bg-warning/10 text-warning border-warning/30";
|
||||
} else {
|
||||
label = t("badgeLow");
|
||||
className = "bg-destructive/10 text-destructive border-destructive/30";
|
||||
|
||||
@@ -41,23 +41,23 @@ export function attendanceStatusToKey(
|
||||
|
||||
/**
|
||||
* 根据考勤状态返回 Tailwind 徽章语义类名。
|
||||
* - present → emerald
|
||||
* - present → success
|
||||
* - absent → destructive
|
||||
* - late → amber
|
||||
* - leave → sky
|
||||
* - late → warning
|
||||
* - leave → info
|
||||
*/
|
||||
export function attendanceStatusToBadgeClass(
|
||||
status: string | null | undefined,
|
||||
): string {
|
||||
switch (attendanceStatusToKey(status)) {
|
||||
case "present":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
return "bg-success/10 text-success";
|
||||
case "absent":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "late":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
return "bg-warning/10 text-warning";
|
||||
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.95 → emerald(优秀)
|
||||
* - >= 0.9 → amber(一般)
|
||||
* - >= 0.95 → success(优秀)
|
||||
* - >= 0.9 → warning(一般)
|
||||
* - 其他 → destructive(低出勤率)
|
||||
*/
|
||||
export function presentRateToColorClass(
|
||||
@@ -84,8 +84,8 @@ export function presentRateToColorClass(
|
||||
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (rate >= 0.95) return "text-emerald-600 dark:text-emerald-400";
|
||||
if (rate >= 0.9) return "text-amber-600 dark:text-amber-400";
|
||||
if (rate >= 0.95) return "text-success";
|
||||
if (rate >= 0.9) return "text-warning";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
FilterBar,
|
||||
FilterSearchInput,
|
||||
@@ -176,45 +177,45 @@ export function AuditLogsListClient(): React.ReactElement {
|
||||
value={userId}
|
||||
onChange={(v) => updateQuery("userId", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={moduleFilter}
|
||||
onChange={(e) => updateQuery("module", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("module", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allModules")}</option>
|
||||
{(moduleOptions ?? []).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
/>
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onChange={(e) => updateQuery("action", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("action", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allActions")}</option>
|
||||
{ACTION_OPTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{auditActionToLabel(a)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("status", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allStatuses")}</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{auditStatusToLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
|
||||
@@ -27,6 +27,16 @@ import {
|
||||
useSaveAuditRetentionConfig,
|
||||
usePurgeExpiredAuditLogs,
|
||||
} 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 {
|
||||
Card,
|
||||
@@ -54,6 +64,7 @@ export function AuditRetentionSettings(): React.ReactElement {
|
||||
const { run: purgeLogs, loading: isPurging } = usePurgeExpiredAuditLogs();
|
||||
|
||||
const [config, setConfig] = useState<AuditRetentionConfig | null>(null);
|
||||
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false);
|
||||
|
||||
// 同步加载的配置到本地状态
|
||||
useEffect(() => {
|
||||
@@ -74,8 +85,6 @@ export function AuditRetentionSettings(): React.ReactElement {
|
||||
|
||||
const handlePurge = async (): Promise<void> => {
|
||||
if (!config) return;
|
||||
const confirmed = window.confirm(t("purgeConfirm"));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const result = await purgeLogs(
|
||||
config.retentionDays,
|
||||
@@ -216,7 +225,7 @@ export function AuditRetentionSettings(): React.ReactElement {
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void handlePurge()}
|
||||
onClick={() => setPurgeConfirmOpen(true)}
|
||||
disabled={isPurging}
|
||||
>
|
||||
{isPurging ? (
|
||||
@@ -227,6 +236,27 @@ export function AuditRetentionSettings(): React.ReactElement {
|
||||
{t("purge")}
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
FilterBar,
|
||||
FilterSearchInput,
|
||||
@@ -169,32 +170,31 @@ export function DataChangesClient(): React.ReactElement {
|
||||
value={userId}
|
||||
onChange={(v) => updateQuery("userId", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={tableFilter}
|
||||
onChange={(e) => updateQuery("table", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("table", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allTables")}</option>
|
||||
{(tableOptions ?? []).map((tb) => (
|
||||
<option key={tb} value={tb}>
|
||||
{tb}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
/>
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onChange={(e) => updateQuery("action", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("action", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allActions")}</option>
|
||||
{ACTION_OPTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{t(a.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { LoginLog } from "@/lib/api";
|
||||
import { useLoginLogs, useExportLoginLogs } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
FilterBar,
|
||||
FilterSearchInput,
|
||||
@@ -164,32 +165,34 @@ export function LoginLogsClient(): React.ReactElement {
|
||||
value={userId}
|
||||
onChange={(v) => updateQuery("userId", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onChange={(e) => updateQuery("action", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("action", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allActions")}</option>
|
||||
{ACTION_OPTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{t(a.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("status", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allStatuses")}</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{t(s.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
|
||||
@@ -316,7 +316,7 @@ function PlanProgress({
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<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 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +209,7 @@ function HeatmapCard({
|
||||
<div className="font-mono text-xs">
|
||||
{formatCoverageRate(rate)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] opacity-80">
|
||||
<div className="mt-1 text-xs opacity-80">
|
||||
{formatLessonPlanCount(cell.linked)}/
|
||||
{formatLessonPlanCount(cell.total)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useFileAttachments, useFileStats } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -170,19 +171,21 @@ export function FilesListClient(): React.ReactElement {
|
||||
onChange={setSearch}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={fileType}
|
||||
onChange={(e) => updateFileType(e.target.value)}
|
||||
onValueChange={(v) => updateFileType(v)}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -125,16 +125,16 @@ describe("invitationStatusToKey", () => {
|
||||
});
|
||||
|
||||
describe("invitationStatusToBadgeClass", () => {
|
||||
it("returns emerald class for active", () => {
|
||||
expect(invitationStatusToBadgeClass("active")).toContain("emerald");
|
||||
it("returns success class for active", () => {
|
||||
expect(invitationStatusToBadgeClass("active")).toContain("success");
|
||||
});
|
||||
|
||||
it("returns blue class for used", () => {
|
||||
expect(invitationStatusToBadgeClass("used")).toContain("blue");
|
||||
expect(invitationStatusToBadgeClass("used")).toContain("info");
|
||||
});
|
||||
|
||||
it("returns amber class for expired", () => {
|
||||
expect(invitationStatusToBadgeClass("expired")).toContain("amber");
|
||||
it("returns warning class for expired", () => {
|
||||
expect(invitationStatusToBadgeClass("expired")).toContain("warning");
|
||||
});
|
||||
|
||||
it("returns muted for revoked", () => {
|
||||
|
||||
@@ -284,11 +284,11 @@ export function GenerateInvitationCodesDialog({
|
||||
<div className="space-y-4">
|
||||
{/* 成功/失败统计 */}
|
||||
<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">
|
||||
{t("statsSuccess")}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<p className="text-2xl font-semibold text-success">
|
||||
{successCount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterBar } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -233,19 +234,20 @@ export function InvitationCodesListClient(): React.ReactElement {
|
||||
}
|
||||
filters={
|
||||
<FilterBar variant="wrap">
|
||||
<select
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
onValueChange={(v) => updateQuery("status", v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("allStatuses")}</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(invitationStatusToKey(s))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FilterBar>
|
||||
}
|
||||
loading={loading}
|
||||
|
||||
@@ -77,11 +77,11 @@ export function invitationStatusToKey(status: string): string {
|
||||
export function invitationStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
return "bg-success/10 text-success";
|
||||
case "used":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
return "bg-info/10 text-info";
|
||||
case "expired":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
return "bg-warning/10 text-warning";
|
||||
case "revoked":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教案软删除确认对话框(轻量自实现模态,ARCHITECTURE.md §7.3 / §9.4)
|
||||
* 教案软删除确认对话框(基于 shadcn AlertDialog,ARCHITECTURE.md §7.3 / §9.4)
|
||||
*
|
||||
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
|
||||
* 结构:fixed inset-0 + bg-black/50 + 居中卡片。
|
||||
* 结构:AlertDialog + AlertDialogContent。
|
||||
* 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
|
||||
*/
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
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 {
|
||||
/** 是否打开 */
|
||||
@@ -39,55 +47,34 @@ export function DeleteConfirmDialog({
|
||||
}: DeleteConfirmDialogProps): React.ReactElement | null {
|
||||
const t = useTranslations("admin.lessonPlans.delete");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape" && !loading) {
|
||||
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")}
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !loading) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("description")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -98,9 +85,9 @@ export function DeleteConfirmDialog({
|
||||
) : (
|
||||
t("confirm")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DeleteConfirmDialog } from "@/features/admin/lesson-plans/delete-confir
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import {
|
||||
ListPageShell,
|
||||
@@ -180,18 +181,20 @@ export function AdminLessonPlansListClient(): React.ReactElement {
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
aria-label={t("statusFilter")}
|
||||
>
|
||||
<option value="">{t("allStatuses")}</option>
|
||||
<option value="DRAFT">{t("statusDraft")}</option>
|
||||
<option value="PUBLISHED">{t("statusPublished")}</option>
|
||||
<option value="ARCHIVED">{t("statusArchived")}</option>
|
||||
<option value="SUBMITTED">{t("statusSubmitted")}</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: t("allStatuses") },
|
||||
{ value: "DRAFT", label: t("statusDraft") },
|
||||
{ value: "PUBLISHED", label: t("statusPublished") },
|
||||
{ value: "ARCHIVED", label: t("statusArchived") },
|
||||
{ value: "SUBMITTED", label: t("statusSubmitted") },
|
||||
]}
|
||||
placeholder={t("allStatuses")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useCreateQuestion } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/** 题型选项(与 schema Question.type 字符串语义对齐) */
|
||||
@@ -138,18 +139,16 @@ export function CreateQuestionDialog({
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FormField label={t("fieldType")} required>
|
||||
<select
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
onValueChange={(v) => setType(v)}
|
||||
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]"
|
||||
>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{t(`types.${opt}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("fieldContent")} required>
|
||||
@@ -189,18 +188,16 @@ export function CreateQuestionDialog({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label={t("fieldDifficulty")} required>
|
||||
<select
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||
<Select
|
||||
value={String(difficulty)}
|
||||
onValueChange={(v) => setDifficulty(Number(v))}
|
||||
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]"
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("fieldKnowledgePoint")} required>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useAdminQuestions, type AdminQuestionListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -202,30 +203,34 @@ export function AdminQuestionsListClient(): React.ReactElement {
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
aria-label={t("filterType")}
|
||||
>
|
||||
<option value="">{t("typeAll")}</option>
|
||||
<option value="single_choice">{t("typeSingle")}</option>
|
||||
<option value="multiple_choice">{t("typeMultiple")}</option>
|
||||
<option value="fill_blank">{t("typeFill")}</option>
|
||||
<option value="essay">{t("typeEssay")}</option>
|
||||
<option value="true_false">{t("typeTrueFalse")}</option>
|
||||
</select>
|
||||
<select
|
||||
options={[
|
||||
{ value: "", label: t("typeAll") },
|
||||
{ value: "single_choice", label: t("typeSingle") },
|
||||
{ value: "multiple_choice", label: t("typeMultiple") },
|
||||
{ value: "fill_blank", label: t("typeFill") },
|
||||
{ value: "essay", label: t("typeEssay") },
|
||||
{ value: "true_false", label: t("typeTrueFalse") },
|
||||
]}
|
||||
placeholder={t("typeAll")}
|
||||
/>
|
||||
<Select
|
||||
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"
|
||||
aria-label={t("filterDifficulty")}
|
||||
>
|
||||
<option value="">{t("difficultyAll")}</option>
|
||||
<option value="easy">{t("difficultyEasy")}</option>
|
||||
<option value="medium">{t("difficultyMedium")}</option>
|
||||
<option value="hard">{t("difficultyHard")}</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: t("difficultyAll") },
|
||||
{ value: "easy", label: t("difficultyEasy") },
|
||||
{ value: "medium", label: t("difficultyMedium") },
|
||||
{ value: "hard", label: t("difficultyHard") },
|
||||
]}
|
||||
placeholder={t("difficultyAll")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
|
||||
@@ -31,20 +31,20 @@ describe("scheduleStatusToKey", () => {
|
||||
});
|
||||
|
||||
describe("scheduleStatusToBadgeClass", () => {
|
||||
it("returns emerald for scheduled", () => {
|
||||
expect(scheduleStatusToBadgeClass("scheduled")).toContain("emerald");
|
||||
it("returns success for scheduled", () => {
|
||||
expect(scheduleStatusToBadgeClass("scheduled")).toContain("success");
|
||||
});
|
||||
|
||||
it("returns destructive for failed", () => {
|
||||
expect(scheduleStatusToBadgeClass("failed")).toContain("destructive");
|
||||
});
|
||||
|
||||
it("returns amber for pending", () => {
|
||||
expect(scheduleStatusToBadgeClass("pending")).toContain("amber");
|
||||
it("returns warning for pending", () => {
|
||||
expect(scheduleStatusToBadgeClass("pending")).toContain("warning");
|
||||
});
|
||||
|
||||
it("returns amber for unknown (fallback)", () => {
|
||||
expect(scheduleStatusToBadgeClass("unknown")).toContain("amber");
|
||||
it("returns warning for unknown (fallback)", () => {
|
||||
expect(scheduleStatusToBadgeClass("unknown")).toContain("warning");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ describe("scheduleChangeStatusToKey", () => {
|
||||
});
|
||||
|
||||
describe("scheduleChangeStatusToBadgeClass", () => {
|
||||
it("returns emerald for approved", () => {
|
||||
expect(scheduleChangeStatusToBadgeClass("approved")).toContain("emerald");
|
||||
it("returns success for approved", () => {
|
||||
expect(scheduleChangeStatusToBadgeClass("approved")).toContain("success");
|
||||
});
|
||||
|
||||
it("returns destructive for rejected", () => {
|
||||
@@ -110,8 +110,8 @@ describe("scheduleChangeStatusToBadgeClass", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns amber for pending", () => {
|
||||
expect(scheduleChangeStatusToBadgeClass("pending")).toContain("amber");
|
||||
it("returns warning for pending", () => {
|
||||
expect(scheduleChangeStatusToBadgeClass("pending")).toContain("warning");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
import { useAdminClasses, useAutoSchedule } from "@/lib/api";
|
||||
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 { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
@@ -59,7 +69,6 @@ export function AutoScheduleClient(): React.ReactElement {
|
||||
const classes = data ?? [];
|
||||
|
||||
const handleStart = async (classId: string): Promise<void> => {
|
||||
if (!window.confirm(t("startConfirm"))) return;
|
||||
// 标记为 pending(覆盖之前结果)
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
@@ -157,6 +166,8 @@ function AutoScheduleTable({
|
||||
onStart: (classId: string) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("admin.scheduling.auto");
|
||||
const tCommon = useTranslations("common");
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
@@ -200,7 +211,7 @@ function AutoScheduleTable({
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={running}
|
||||
onClick={() => void onStart(cls.id)}
|
||||
onClick={() => setConfirmId(cls.id)}
|
||||
>
|
||||
{t("startButton")}
|
||||
</Button>
|
||||
@@ -210,6 +221,30 @@ function AutoScheduleTable({
|
||||
})}
|
||||
</tbody>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
@@ -25,6 +25,16 @@ import {
|
||||
useRejectScheduleChange,
|
||||
} from "@/lib/api";
|
||||
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 { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
@@ -92,7 +102,6 @@ export function ScheduleChangesClient(): React.ReactElement {
|
||||
}, [changes, entriesList]);
|
||||
|
||||
const handleApprove = async (id: string): Promise<void> => {
|
||||
if (!window.confirm(t("approveConfirm"))) return;
|
||||
try {
|
||||
await approveChange(id);
|
||||
notify.success(t("approveSuccess"));
|
||||
@@ -182,6 +191,8 @@ function ChangesTable({
|
||||
onReject: (id: string) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("admin.scheduling.changes");
|
||||
const tCommon = useTranslations("common");
|
||||
const [approveConfirmId, setApproveConfirmId] = useState<string | null>(null);
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -245,7 +256,7 @@ function ChangesTable({
|
||||
{t("conflictDetect", { count: conflicts.length })}
|
||||
</p>
|
||||
) : isPending ? (
|
||||
<p className="mt-1 text-xs text-emerald-600">
|
||||
<p className="mt-1 text-xs text-success">
|
||||
{t("noConflict")}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -257,7 +268,7 @@ function ChangesTable({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={approving || rejecting}
|
||||
onClick={() => void onApprove(change.id)}
|
||||
onClick={() => setApproveConfirmId(change.id)}
|
||||
>
|
||||
{t("approveButton")}
|
||||
</Button>
|
||||
@@ -282,6 +293,32 @@ function ChangesTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,16 @@ import {
|
||||
type AdminSchedulingRule,
|
||||
} from "@/lib/api/admin-p5";
|
||||
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 { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
@@ -152,6 +162,7 @@ export function SchedulingRulesClient(): React.ReactElement {
|
||||
const [numberValues, setNumberValues] = useState<Record<string, string>>({});
|
||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
|
||||
// 同步加载到的规则到本地状态
|
||||
useEffect(() => {
|
||||
@@ -191,10 +202,14 @@ export function SchedulingRulesClient(): React.ReactElement {
|
||||
};
|
||||
|
||||
const handleReset = (): void => {
|
||||
if (!window.confirm(t("resetConfirm"))) return;
|
||||
setResetConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmReset = (): void => {
|
||||
setNumberValues({});
|
||||
setEnabledMap({});
|
||||
void refetch();
|
||||
setResetConfirmOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
@@ -324,6 +339,20 @@ export function SchedulingRulesClient(): React.ReactElement {
|
||||
</SectionBlock>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ export function scheduleStatusToKey(status: string): ScheduleStatus {
|
||||
export function scheduleStatusToBadgeClass(status: string): string {
|
||||
switch (scheduleStatusToKey(status)) {
|
||||
case "scheduled":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
return "bg-success/10 text-success";
|
||||
case "failed":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "pending":
|
||||
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 {
|
||||
switch (scheduleChangeStatusToKey(status)) {
|
||||
case "approved":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
return "bg-success/10 text-success";
|
||||
case "rejected":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "pending":
|
||||
default:
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
return "bg-warning/10 text-warning";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ describe("truncateText", () => {
|
||||
});
|
||||
|
||||
describe("activeToBadgeClass", () => {
|
||||
it("returns emerald class for active", () => {
|
||||
expect(activeToBadgeClass(true)).toContain("emerald");
|
||||
it("returns success class for active", () => {
|
||||
expect(activeToBadgeClass(true)).toContain("success");
|
||||
});
|
||||
|
||||
it("returns muted class for inactive", () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -205,19 +206,20 @@ export function AcademicYearClient(): React.ReactElement {
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
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}
|
||||
@@ -321,10 +323,7 @@ function ActiveYearSidebarCard({
|
||||
</CardTitle>
|
||||
<CardDescription>{t("activeYearCardDescription")}</CardDescription>
|
||||
</div>
|
||||
<CheckCircle2
|
||||
className="size-5 text-emerald-600 dark:text-emerald-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckCircle2 className="size-5 text-success" aria-hidden="true" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
@@ -545,19 +544,19 @@ function AcademicYearFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldSchool")} required>
|
||||
<select
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
required
|
||||
onValueChange={(v) => setSchoolId(v)}
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldStartDate")} required>
|
||||
<Input
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -211,32 +212,34 @@ export function AdminClassesClient(): React.ReactElement {
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
aria-label={t("filterSchool")}
|
||||
>
|
||||
<option value="">{t("allSchools")}</option>
|
||||
{(schools ?? []).map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
/>
|
||||
<Select
|
||||
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"
|
||||
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}
|
||||
@@ -505,48 +508,49 @@ function ClassFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldSchool")} required>
|
||||
<select
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
required
|
||||
onValueChange={(v) => setSchoolId(v)}
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldGrade")} required>
|
||||
<select
|
||||
<Select
|
||||
value={gradeId}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
required
|
||||
onValueChange={(v) => setGradeId(v)}
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldHeadTeacher")}>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{teacherOptions.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldHomeroomLabel")}>
|
||||
<Input
|
||||
@@ -565,18 +569,19 @@ function ClassFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldHomeroom")}>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{teacherOptions.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级邀请码管理 - 客户端组件(ARCHITECTURE.md §7.3 / §9.4 / §10 P5)
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import { FormField } from "@/features/admin/school/schools-client";
|
||||
|
||||
/** 星期 1-7 */
|
||||
@@ -346,32 +347,26 @@ function ScheduleFormDialog({
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label={t("form.fieldWeekday")} required>
|
||||
<select
|
||||
value={weekday}
|
||||
onChange={(e) => setWeekday(Number(e.target.value))}
|
||||
<Select
|
||||
value={String(weekday)}
|
||||
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"
|
||||
required
|
||||
>
|
||||
{WEEKDAY_OPTIONS.map((w) => (
|
||||
<option key={w} value={w}>
|
||||
{t(`weekday.${w}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldPeriod")} required>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(Number(e.target.value))}
|
||||
<Select
|
||||
value={String(period)}
|
||||
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"
|
||||
required
|
||||
>
|
||||
{PERIOD_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t("form.periodN", { n: p })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -185,19 +186,20 @@ export function DepartmentsClient(): React.ReactElement {
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
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}
|
||||
@@ -392,19 +394,19 @@ function DepartmentFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldSchool")} required>
|
||||
<select
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
required
|
||||
onValueChange={(v) => setSchoolId(v)}
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldHead")}>
|
||||
<Input
|
||||
|
||||
@@ -397,8 +397,8 @@ export function GradeInsightsClient(): React.ReactElement {
|
||||
<span
|
||||
className={
|
||||
isUp
|
||||
? "inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400"
|
||||
: "inline-flex items-center gap-1 text-rose-600 dark:text-rose-400"
|
||||
? "inline-flex items-center gap-1 text-success"
|
||||
: "inline-flex items-center gap-1 text-destructive"
|
||||
}
|
||||
>
|
||||
<DeltaIcon
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import {
|
||||
ListPageShell,
|
||||
@@ -231,19 +232,20 @@ export function GradesClient(): React.ReactElement {
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
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}
|
||||
@@ -441,7 +443,7 @@ function GradeOverviewCards({
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||
{formatCount(stats?.classCount ?? g.classCount)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("statsClassCount")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,7 +454,7 @@ function GradeOverviewCards({
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||
{formatCount(stats?.studentCount ?? g.studentCount)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("statsStudentCount")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +465,7 @@ function GradeOverviewCards({
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||
{formatScore(stats?.avgScore ?? null)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("statsAvgScore")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,33 +644,34 @@ function GradeFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldSchool")} required>
|
||||
<select
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
required
|
||||
onValueChange={(v) => setSchoolId(v)}
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("form.fieldHeadStaff")}>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{staffOptions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function truncateText(text: string, maxLen = 30): string {
|
||||
*/
|
||||
export function activeToBadgeClass(isActive: boolean): string {
|
||||
return isActive
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { AdminStudent } from "@/lib/api/admin-p5";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -59,16 +60,11 @@ export function StudentsListClient(): React.ReactElement {
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const pageParam = Number(searchParams.get("page") ?? "1");
|
||||
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAdminStudents(
|
||||
{
|
||||
gradeId: gradeId || null,
|
||||
classId: classId || null,
|
||||
},
|
||||
{ limit: PAGE_SIZE, offset },
|
||||
);
|
||||
const { data, loading, error } = useAdminStudents({
|
||||
gradeId: gradeId || null,
|
||||
classId: classId || null,
|
||||
});
|
||||
|
||||
const { data: gradesData } = useGrades();
|
||||
const { data: classesData } = useAdminClasses();
|
||||
@@ -165,32 +161,28 @@ export function StudentsListClient(): React.ReactElement {
|
||||
value={search}
|
||||
onChange={(v) => updateQuery("search", v, true)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={gradeId}
|
||||
onChange={(e) => handleGradeChange(e.target.value)}
|
||||
onValueChange={(v) => handleGradeChange(v)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("list.allGrades")}</option>
|
||||
{gradeOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
/>
|
||||
<Select
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value, true)}
|
||||
onValueChange={(v) => updateQuery("classId", v, true)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("list.allClasses")}</option>
|
||||
{classOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
|
||||
@@ -24,8 +24,19 @@ import {
|
||||
type SystemSettingInput,
|
||||
} from "@/lib/api";
|
||||
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 { Input } from "@/shared/components/ui/input";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
FormPageShell,
|
||||
FormPageSkeleton,
|
||||
@@ -97,6 +108,7 @@ export function SystemSettingsClient(): React.ReactElement {
|
||||
const [storageBucket, setStorageBucket] = useState("");
|
||||
const [storageRegion, setStorageRegion] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
|
||||
// 当外部数据加载完成后,同步到本地表单状态
|
||||
useEffect(() => {
|
||||
@@ -259,11 +271,15 @@ export function SystemSettingsClient(): React.ReactElement {
|
||||
};
|
||||
|
||||
const handleReset = (): void => {
|
||||
if (!window.confirm(t("resetConfirm"))) return;
|
||||
setResetConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmReset = (): void => {
|
||||
if (data && data.length > 0) {
|
||||
// 触发 useEffect 重新同步:通过 refetch 间接完成
|
||||
void refetch();
|
||||
}
|
||||
setResetConfirmOpen(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -339,14 +355,15 @@ export function SystemSettingsClient(): React.ReactElement {
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("generalLanguage")}>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
/>
|
||||
</FormField>
|
||||
</SectionBlock>
|
||||
|
||||
@@ -432,17 +449,19 @@ export function SystemSettingsClient(): React.ReactElement {
|
||||
{/* 存储配置分区 */}
|
||||
<SectionBlock title={t("sectionStorage")}>
|
||||
<FormField label={t("storageProvider")}>
|
||||
<select
|
||||
<Select
|
||||
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"
|
||||
>
|
||||
<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 label={t("storageBucket")}>
|
||||
<Input
|
||||
@@ -462,6 +481,20 @@ export function SystemSettingsClient(): React.ReactElement {
|
||||
</SectionBlock>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { AdminTeacher } from "@/lib/api/admin-p5";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -53,15 +54,10 @@ export function TeachersListClient(): React.ReactElement {
|
||||
const departmentId = searchParams.get("departmentId") ?? "";
|
||||
const pageParam = Number(searchParams.get("page") ?? "1");
|
||||
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAdminTeachers(
|
||||
{
|
||||
department: departmentId || null,
|
||||
},
|
||||
{ limit: PAGE_SIZE, offset },
|
||||
);
|
||||
const { data, loading, error } = useAdminTeachers({
|
||||
department: departmentId || null,
|
||||
});
|
||||
|
||||
const { data: departmentsData } = useDepartments();
|
||||
const departmentOptions = useMemo(
|
||||
@@ -140,19 +136,17 @@ export function TeachersListClient(): React.ReactElement {
|
||||
value={search}
|
||||
onChange={(v) => updateQuery("search", v, true)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={departmentId}
|
||||
onChange={(e) => updateQuery("departmentId", e.target.value, true)}
|
||||
onValueChange={(v) => updateQuery("departmentId", v, true)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("list.allDepartments")}</option>
|
||||
{departmentOptions.map((d) => (
|
||||
<option key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { Select } from "@/shared/components/ui/select";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
@@ -248,19 +249,17 @@ export function UsersListClient(): React.ReactElement {
|
||||
value={search}
|
||||
onChange={(v) => updateQuery("search", v, true)}
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={role}
|
||||
onChange={(e) => updateQuery("role", e.target.value, true)}
|
||||
onValueChange={(v) => updateQuery("role", v, true)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("list.allRoles")}</option>
|
||||
{USER_ROLE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
{(search || role) && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -3,39 +3,43 @@
|
||||
/**
|
||||
* 学生考勤页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentAttendance:❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-attendance
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 数据契约:studentAttendance ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 三态规范(§11.3 DoD):loading / error + notify.error / empty
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
* 复刻 CICD:student-attendance-view.tsx
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useStudentAttendance } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 考勤客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function StudentAttendanceClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentAttendance();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||
}
|
||||
}, [error, tCommon]);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -47,14 +51,14 @@ export function StudentAttendanceClient(): React.ReactElement {
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState icon={ClipboardCheck} title={t("emptyTitle")} />
|
||||
<EmptyState icon={CalendarCheck} title={t("emptyTitle")} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
icon={<CalendarCheck className="size-6" />}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
@@ -65,9 +69,6 @@ export function StudentAttendanceClient(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤内容区(汇总卡片 + 明细列表)。
|
||||
*/
|
||||
function AttendanceBody({
|
||||
data,
|
||||
}: {
|
||||
@@ -76,11 +77,46 @@ function AttendanceBody({
|
||||
const t = useTranslations("studentDomain.attendance");
|
||||
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")}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
title={t("fieldAttendanceRate")}
|
||||
value={formatRate(data.rate)}
|
||||
title={t("fieldTotalRecords")}
|
||||
value={String(data.totalRecords ?? data.records.length)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldPresentCount")}
|
||||
value={String(data.presentCount ?? "--")}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldAbsentCount")}
|
||||
value={String(data.absentCount)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldLateCount")}
|
||||
@@ -95,8 +131,16 @@ function AttendanceBody({
|
||||
value={String(data.leaveCount)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldAbsentCount")}
|
||||
value={String(data.absentCount)}
|
||||
title={t("fieldSchoolActivityCount")}
|
||||
value={String(data.schoolActivityCount ?? "--")}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldAttendanceRate")}
|
||||
value={formatRate(data.rate)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldLateRate")}
|
||||
value={formatRate(data.lateRate)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
@@ -108,9 +152,6 @@ function AttendanceBody({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤明细表格。
|
||||
*/
|
||||
function AttendanceRecordsTable({
|
||||
records,
|
||||
}: {
|
||||
@@ -128,6 +169,7 @@ function AttendanceRecordsTable({
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<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("colRemark")}</th>
|
||||
</tr>
|
||||
@@ -138,6 +180,9 @@ function AttendanceRecordsTable({
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatDate(record.date)}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{record.className ?? "--"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<AttendanceStatusBadge status={record.status} />
|
||||
</td>
|
||||
@@ -152,15 +197,13 @@ function AttendanceRecordsTable({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function AttendanceStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const label = formatAttendanceStatus(status);
|
||||
const t = useTranslations("studentDomain.attendance");
|
||||
const label = formatAttendanceStatus(status, t);
|
||||
const cls = attendanceStatusToBadgeClass(status);
|
||||
return (
|
||||
<span
|
||||
@@ -171,21 +214,28 @@ function AttendanceStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
/** 考勤状态中文标签映射。 */
|
||||
const ATTENDANCE_STATUS_LABEL: Record<string, string> = {
|
||||
present: "出勤",
|
||||
late: "迟到",
|
||||
early_leave: "早退",
|
||||
leave: "请假",
|
||||
absent: "缺勤",
|
||||
};
|
||||
|
||||
/** 将考勤状态枚举值映射为中文标签。未知状态回退为原始值。 */
|
||||
function formatAttendanceStatus(status: string): string {
|
||||
return ATTENDANCE_STATUS_LABEL[status] ?? status;
|
||||
function formatAttendanceStatus(
|
||||
status: string,
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "present":
|
||||
return t("status.present");
|
||||
case "late":
|
||||
return t("status.late");
|
||||
case "early_leave":
|
||||
return t("status.early_leave");
|
||||
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 {
|
||||
switch (status) {
|
||||
case "present":
|
||||
@@ -198,18 +248,19 @@ function attendanceStatusToBadgeClass(status: string): string {
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
case "absent":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "school_activity":
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化 0-1 的出勤率为百分比字符串。 */
|
||||
function formatRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
function formatRate(rate: number | undefined): string {
|
||||
if (rate === undefined || !Number.isFinite(rate) || rate < 0 || rate > 1)
|
||||
return "--";
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** 格式化 ISO 日期字符串为本地化展示。 */
|
||||
function formatDate(isoDate: string): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentCoursePlanDetail schema 未实现,全 MSW 兜底
|
||||
/**
|
||||
* 学生课程计划详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
@@ -10,33 +9,42 @@
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - error:errorNode 局部降级 + notify.error
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 学生视角:只读,无拖拽/编辑/删除/批量操作。
|
||||
*
|
||||
* 关联: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 { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, type ReactElement } from "react";
|
||||
|
||||
import {
|
||||
useStudentCoursePlanDetail,
|
||||
type StudentCoursePlanDetail as CoursePlanDetailData,
|
||||
type StudentCoursePlanItem,
|
||||
type StudentCoursePlanItemStatus,
|
||||
} 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 {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function StudentCoursePlanDetailClient(): React.ReactElement {
|
||||
/** 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 */
|
||||
export function StudentCoursePlanDetailClient(): ReactElement {
|
||||
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -86,41 +94,425 @@ export function StudentCoursePlanDetailClient(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情主体(基本信息 + 关联教材列表)。
|
||||
*/
|
||||
/** 详情主体(Header Card + 周计划表格)。 */
|
||||
function CoursePlanDetailBody({
|
||||
plan,
|
||||
}: {
|
||||
plan: CoursePlanDetailData;
|
||||
}): React.ReactElement {
|
||||
}): ReactElement {
|
||||
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")}>
|
||||
{plan.textbooks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{plan.textbooks.map((title, idx) => (
|
||||
<li
|
||||
key={`${title}-${idx}`}
|
||||
className="flex items-center gap-2 rounded-md border bg-card p-3 text-sm"
|
||||
>
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DetailSection>
|
||||
</>
|
||||
const handleExport = (): void => {
|
||||
try {
|
||||
const csv = exportPlanAsCsv(plan, t);
|
||||
downloadCsv(
|
||||
csv,
|
||||
`${plan.subject}-${plan.className ?? "no-class"}-course-plan.csv`,
|
||||
);
|
||||
notify.success(t("toastExported"));
|
||||
} catch {
|
||||
notify.error(t("toastExportFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SectionErrorBoundary title={t("sectionBasic")}>
|
||||
<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);
|
||||
}
|
||||
|
||||
@@ -8,29 +8,59 @@
|
||||
* - studentCoursePlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* URL 状态:?status=(客户端按状态过滤)
|
||||
*
|
||||
* 视图结构(Card 网格布局):
|
||||
* - 状态筛选 Select(全部/进行中/已完成/计划中/已暂停)
|
||||
* - 课程计划 Card 网格(标题 + 班级/教师/学期/进度条 + 状态徽章)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useStudentCoursePlans, type StudentCoursePlan } from "@/lib/api";
|
||||
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 {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
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 {
|
||||
const t = useTranslations("studentDomain.coursePlans.list");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentCoursePlans();
|
||||
@@ -43,6 +73,37 @@ export function StudentCoursePlansListClient(): React.ReactElement {
|
||||
|
||||
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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -68,69 +129,236 @@ export function StudentCoursePlansListClient(): React.ReactElement {
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
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} />}
|
||||
empty={items.length === 0 && !loading}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("total", { count: items.length })}</span>
|
||||
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span className="text-xs">{t("mswNotice")}</span>
|
||||
<span>{t("total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StudentCoursePlansTable items={items} />
|
||||
<CoursePlanCardGrid items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生课程计划列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
* 课程计划 Card 网格(小屏 1 列,桌面 2-3 列)。
|
||||
*/
|
||||
function StudentCoursePlansTable({
|
||||
function CoursePlanCardGrid({
|
||||
items,
|
||||
}: {
|
||||
items: StudentCoursePlan[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.coursePlans.list");
|
||||
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("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) => (
|
||||
<tr key={plan.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/student/course-plans/${plan.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.subject}</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.grade}</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.status}</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/student/course-plans/${plan.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((plan) => (
|
||||
<CoursePlanCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</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
|
||||
href={`/shell/student/course-plans/${plan.id}`}
|
||||
className="line-clamp-2 font-medium hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{t("colSubject")}
|
||||
</span>
|
||||
: {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
|
||||
href={`/shell/student/course-plans/${plan.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,18 +13,22 @@
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 布局:3 卡片网格(基本信息 / 教师信息 / 本班课表),小屏单列堆叠。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { GraduationCap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import {
|
||||
useStudentCourseDetail,
|
||||
type StudentCourseDetail as StudentCourseDetailData,
|
||||
type StudentCourseScheduleItem,
|
||||
} 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 {
|
||||
@@ -35,6 +39,46 @@ import {
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/** Weekday i18n key mapping(1=周一 ... 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> 中。
|
||||
*/
|
||||
@@ -89,7 +133,8 @@ export function StudentCourseDetailClient(): React.ReactElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情主体(基本信息 + 教师信息 + 本班课表)。
|
||||
* 详情主体(3 卡片网格:基本信息 + 教师信息 + 本班课表)。
|
||||
* 小屏单列堆叠,lg+ 三列网格。
|
||||
*/
|
||||
function CourseDetailBody({
|
||||
course,
|
||||
@@ -98,16 +143,21 @@ function CourseDetailBody({
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.courses.detail");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("sectionBasic")}>
|
||||
<DetailField label={t("fieldName")} value={course.name} />
|
||||
<DetailField label={t("fieldGrade")} value={course.grade} />
|
||||
<DetailField label={t("fieldHeadTeacher")} value={course.headTeacher} />
|
||||
<DetailField label={t("fieldClassroom")} value={course.classroom} />
|
||||
<DetailField label={t("fieldSchool")} value={course.school} />
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<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("fieldGrade")} value={course.grade} />
|
||||
<DetailField
|
||||
label={t("fieldHeadTeacher")}
|
||||
value={course.headTeacher}
|
||||
/>
|
||||
<DetailField label={t("fieldClassroom")} value={course.classroom} />
|
||||
<DetailField label={t("fieldSchool")} value={course.school} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("sectionTeachers")}>
|
||||
<DetailSection title={t("sectionTeachers")} className="lg:col-span-1">
|
||||
{course.teachers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
) : (
|
||||
@@ -138,20 +188,28 @@ function CourseDetailBody({
|
||||
|
||||
<DetailSection
|
||||
title={t("sectionSchedule")}
|
||||
className="lg:col-span-1"
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
{t("viewFullSchedule")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/shell/student/homework">{t("viewHomework")}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/shell/student/schedule">
|
||||
{t("viewFullSchedule")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ScheduleTable schedule={course.schedule} />
|
||||
</DetailSection>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本班课表表格。
|
||||
* 本班课表表格(按星期/节次排序,含 weekday 徽章与全部字段)。
|
||||
*/
|
||||
function ScheduleTable({
|
||||
schedule,
|
||||
@@ -159,28 +217,45 @@ function ScheduleTable({
|
||||
schedule: StudentCourseScheduleItem[];
|
||||
}): React.ReactElement {
|
||||
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 (
|
||||
<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("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("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>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{schedule.map((item, idx) => (
|
||||
{sorted.map((item, idx) => (
|
||||
<tr
|
||||
key={`${item.weekday}-${item.period}-${idx}`}
|
||||
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 text-muted-foreground">{item.teacher}</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -6,28 +6,65 @@
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentCourses(q) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - joinClassByInvitationCode(input) ❌ schema 无此 mutation → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* URL 状态:?q=
|
||||
*
|
||||
* 视图模式:card(默认,卡片网格)/ table(表格)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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 { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
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 { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} 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> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
@@ -40,6 +77,8 @@ export function StudentCoursesListClient(): React.ReactElement {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -86,11 +131,56 @@ export function StudentCoursesListClient(): React.ReactElement {
|
||||
description={t("description")}
|
||||
icon={<GraduationCap className="size-6" />}
|
||||
filters={
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
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}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
@@ -103,11 +193,118 @@ export function StudentCoursesListClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StudentCoursesTable items={items} />
|
||||
{viewMode === "card" ? (
|
||||
<StudentCoursesCardGrid items={items} />
|
||||
) : (
|
||||
<StudentCoursesTable items={items} />
|
||||
)}
|
||||
<JoinClassDialog
|
||||
open={joinOpen}
|
||||
onOpenChange={setJoinOpen}
|
||||
onSuccess={handleJoinSuccess}
|
||||
/>
|
||||
</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 排版规范)。
|
||||
*/
|
||||
@@ -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("colSchool")}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -140,11 +339,30 @@ function StudentCoursesTable({
|
||||
{course.name}
|
||||
</Link>
|
||||
</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.headTeacher}
|
||||
</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">
|
||||
<Link
|
||||
href={`/shell/student/courses/${course.id}`}
|
||||
@@ -160,3 +378,111 @@ function StudentCoursesTable({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 加入班级对话框(邀请码)。
|
||||
* @contract-pending:joinClassByInvitationCode 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:electiveCourses 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)
|
||||
*
|
||||
* 数据契约:
|
||||
* - electiveCourses(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 详情复用列表 hook,按 id 客户端过滤(无独立详情 hook)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-elective
|
||||
* Data contract:
|
||||
* - studentElectiveDetail(id) -> schema missing -> MSW fallback (@contract-pending)
|
||||
* - Contract ticket: 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):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:notFound(无匹配课程)
|
||||
* Privacy: student view does not show enrollment list (only course basic info).
|
||||
*
|
||||
* 关联: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 Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
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 {
|
||||
DetailPageShell,
|
||||
@@ -31,12 +33,53 @@ import {
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/** 默认学期(与列表页一致,详情复用列表 hook) */
|
||||
const DEFAULT_TERM_ID = "2026-spring";
|
||||
/** Status badge variant mapping (@contract-pending, aligned with i18n status* keys) */
|
||||
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 {
|
||||
const t = useTranslations("studentDomain.elective.detail");
|
||||
@@ -44,10 +87,14 @@ export function StudentElectiveDetailClient(): React.ReactElement {
|
||||
const params = useParams<{ id: string }>();
|
||||
const courseId = params?.id ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底(详情复用列表 hook,客户端过滤)
|
||||
const { data, loading, error } = useElectiveCourses(DEFAULT_TERM_ID);
|
||||
// @contract-pending: MSW fallback
|
||||
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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
@@ -59,36 +106,98 @@ export function StudentElectiveDetailClient(): React.ReactElement {
|
||||
|
||||
const emptyNode =
|
||||
!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;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={course?.name ?? t("title")}
|
||||
description={t("sectionBasic")}
|
||||
icon={<BookOpen className="size-6" />}
|
||||
actions={statusBadge}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
<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" />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
{course ? (
|
||||
<DetailSection title={t("sectionBasic")}>
|
||||
<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}
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<Link href="/shell/student/elective">
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
{course ? <ElectiveDetailBody course={course} /> : null}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:electiveCourses schema 未实现,全 MSW 兜底
|
||||
// @contract-pending:electiveCourses / studentSelections schema 未实现,全 MSW 兜底
|
||||
/**
|
||||
* 学生选课列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 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
|
||||
*
|
||||
* URL 状态:?q=(客户端按名称过滤,MSW 不支持服务端过滤)
|
||||
* URL 状态:?q=&status=&mode=(客户端按名称/状态/选课模式过滤)
|
||||
*
|
||||
* 两分区结构:
|
||||
* - 我的选课(mock 空态,无独立 hook)
|
||||
* - 可选课程(列表表格 + 选课操作)
|
||||
* 两分区结构(Card 网格布局):
|
||||
* - 我的选课(已选课程 Card 网格 + 退课操作 + 退课确认对话框)
|
||||
* - 可选课程(可选课程 Card 网格 + 选课操作 + 状态/选课模式徽章)
|
||||
*
|
||||
* 筛选器:
|
||||
* - 名称搜索(FilterSearchInput)
|
||||
* - 状态筛选(Select:全部/开放中/已关闭/进行中/已结束)
|
||||
* - 选课模式筛选(Select:全部/先到先得/抽签)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
@@ -22,22 +28,65 @@
|
||||
import { BookOpen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
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 { Button } from "@/shared/components/ui/button";
|
||||
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 {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
|
||||
/** 默认学期(无独立 hook 查询当前学期,前端固定值) */
|
||||
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> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
@@ -50,17 +99,46 @@ export function StudentElectiveListClient(): React.ReactElement {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const modeFilter = searchParams.get("mode") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
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 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 filtered = q
|
||||
? items.filter((c) => c.name.toLowerCase().includes(q.toLowerCase()))
|
||||
: items;
|
||||
const availableItems = availableData ?? [];
|
||||
const selectionItems = selectionsData ?? [];
|
||||
|
||||
// 客户端按名称/状态/模式过滤可选课程
|
||||
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 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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -94,14 +198,11 @@ export function StudentElectiveListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
const emptyAvailableNode = (
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("emptyAvailable")}
|
||||
action={{
|
||||
label: t("title"),
|
||||
href: "/shell/student/elective",
|
||||
}}
|
||||
description={t("mswNotice")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -111,55 +212,272 @@ export function StudentElectiveListClient(): React.ReactElement {
|
||||
description={t("description")}
|
||||
icon={<BookOpen className="size-6" />}
|
||||
filters={
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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} />}
|
||||
empty={filtered.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span className="text-xs">{t("mswNotice")}</span>
|
||||
<span>{t("total", { count: filtered.length })}</span>
|
||||
<span>{t("total", { count: filteredAvailable.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* 我的选课 - mock 空态(无独立 hook 查询已选课程) */}
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<h3 className="mb-3 text-lg font-semibold">
|
||||
{t("sectionMySelections")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("emptyMySelections")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{/* 我的选课 - Card 网格 */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("sectionMySelections")}
|
||||
</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">
|
||||
{t("emptyMySelections")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<SelectionCardGrid
|
||||
items={selectionItems}
|
||||
dropping={dropMutation.loading}
|
||||
onDrop={openDropDialog}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 可选课程 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold">
|
||||
{t("sectionAvailableCourses")}
|
||||
</h3>
|
||||
<ElectiveTable
|
||||
items={filtered}
|
||||
enrolling={enrollMutation.loading}
|
||||
onEnroll={handleEnroll}
|
||||
/>
|
||||
</div>
|
||||
{/* 可选课程 - Card 网格 */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("sectionAvailableCourses")}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("total", { count: filteredAvailable.length })}
|
||||
</span>
|
||||
</div>
|
||||
{filteredAvailable.length === 0 && !loading ? (
|
||||
emptyAvailableNode
|
||||
) : (
|
||||
<AvailableCardGrid
|
||||
items={filteredAvailable}
|
||||
enrolling={enrollMutation.loading}
|
||||
onEnroll={handleEnroll}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选课程表格(纯展示组件,对齐 §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,
|
||||
enrolling,
|
||||
onEnroll,
|
||||
@@ -168,56 +486,187 @@ function ElectiveTable({
|
||||
enrolling: boolean;
|
||||
onEnroll: (courseId: string) => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.elective.list");
|
||||
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("colCourseName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colCapacity")}</th>
|
||||
<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 className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((course) => (
|
||||
<AvailableCard
|
||||
key={course.id}
|
||||
course={course}
|
||||
enrolling={enrolling}
|
||||
onEnroll={onEnroll}
|
||||
/>
|
||||
))}
|
||||
</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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生错题本页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentErrorBook(q, status, source, dueOnly):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - markErrorMastered(id):✅ 已存在(来自 student.ts)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-error-book-v2
|
||||
* 视图结构:
|
||||
* 1. 5 列统计卡(total / new / learning / mastered+掌握率 / toReview 高亮逾期)
|
||||
* 2. 筛选器(keyword / status / source / dueOnly / reset)+ 视图切换(card / table)
|
||||
* 3. Card 网格(默认)或 Table(切换)
|
||||
* 4. 详情弹窗(ErrorBookDetailDialog)+ 变式练习跳转
|
||||
*
|
||||
* URL 状态:?q=xxx &status=xxx &source=xxx &dueOnly=1
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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 { useCallback, useMemo, useState, useTransition } from "react";
|
||||
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 { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { ErrorBookDetailDialog } from "./error-book-detail-dialog";
|
||||
|
||||
/** 错题状态枚举(@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,待后端契约补齐后改为动态拉取) */
|
||||
const SOURCE_OPTIONS = ["exam", "homework", "practice"] as const;
|
||||
/** 错题来源枚举(与 i18n source* key 对齐) */
|
||||
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 {
|
||||
const t = useTranslations("studentDomain.errorBook");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -56,10 +61,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
const source = searchParams.get("source") ?? "";
|
||||
const dueOnly = searchParams.get("dueOnly") === "1";
|
||||
|
||||
// 已掌握的本地乐观更新集合
|
||||
const [masteredIds, setMasteredIds] = useState<Record<string, boolean>>({});
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
||||
const [selectedItem, setSelectedItem] = useState<StudentErrorBookItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
// @contract-pending: MSW fallback
|
||||
const { data, loading, error } = useStudentErrorBookV2({
|
||||
q: q || undefined,
|
||||
status: status || undefined,
|
||||
@@ -76,6 +84,8 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
return items.filter((item) => !masteredIds[item.id]);
|
||||
}, [items, masteredIds]);
|
||||
|
||||
const hasActiveFilters = Boolean(q || status || source || dueOnly);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
@@ -84,7 +94,7 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
params.delete(key);
|
||||
}
|
||||
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");
|
||||
}
|
||||
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"));
|
||||
} catch (err) {
|
||||
notify.error(t("markError"));
|
||||
// 保留错误日志便于诊断(不影响用户)
|
||||
console.error("[student.error-book] markMastered failed:", err);
|
||||
}
|
||||
},
|
||||
[markMastered, t],
|
||||
);
|
||||
|
||||
const handleViewDetail = useCallback((item: StudentErrorBookItem): void => {
|
||||
setSelectedItem(item);
|
||||
}, []);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -123,7 +142,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = <EmptyState icon={BookX} title={t("emptyTitle")} />;
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={BookX}
|
||||
title={t("emptyTitle")}
|
||||
description={t("emptyDescription")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
@@ -144,14 +169,11 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
aria-label={t("statusFilter")}
|
||||
>
|
||||
<option value="">{t("allStatus")}</option>
|
||||
{STATUS_OPTIONS.map((s) => {
|
||||
const labelKey = statusOptionLabelKey(s);
|
||||
return (
|
||||
<option key={s} value={s}>
|
||||
{t(labelKey)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(statusOptionLabelKey(s))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={source}
|
||||
@@ -162,7 +184,7 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
<option value="">{t("allSources")}</option>
|
||||
{SOURCE_OPTIONS.map((src) => (
|
||||
<option key={src} value={src}>
|
||||
{src}
|
||||
{t(sourceOptionLabelKey(src))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -175,6 +197,46 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
/>
|
||||
{t("dueOnlyFilter")}
|
||||
</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}
|
||||
@@ -189,8 +251,26 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
}
|
||||
>
|
||||
{stats ? <StatsSection stats={stats} /> : null}
|
||||
<ErrorBookTable
|
||||
items={visibleItems}
|
||||
{viewMode === "card" ? (
|
||||
<ErrorBookCardGrid
|
||||
items={visibleItems}
|
||||
onMarkMastered={handleMarkMastered}
|
||||
onViewDetail={handleViewDetail}
|
||||
marking={marking}
|
||||
/>
|
||||
) : (
|
||||
<ErrorBookTable
|
||||
items={visibleItems}
|
||||
onMarkMastered={handleMarkMastered}
|
||||
onViewDetail={handleViewDetail}
|
||||
marking={marking}
|
||||
/>
|
||||
)}
|
||||
<ErrorBookDetailDialog
|
||||
item={selectedItem}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null);
|
||||
}}
|
||||
onMarkMastered={handleMarkMastered}
|
||||
marking={marking}
|
||||
/>
|
||||
@@ -198,7 +278,6 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** 错题状态枚举到 i18n key 的映射。 */
|
||||
function statusOptionLabelKey(status: string): string {
|
||||
switch (status) {
|
||||
case "new":
|
||||
@@ -209,12 +288,84 @@ function statusOptionLabelKey(status: string): string {
|
||||
return "statusMastered";
|
||||
case "to_review":
|
||||
return "statusToReview";
|
||||
case "archived":
|
||||
return "statusArchived";
|
||||
default:
|
||||
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({
|
||||
stats,
|
||||
}: {
|
||||
@@ -222,32 +373,238 @@ function StatsSection({
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.errorBook");
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<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)} />
|
||||
<StatsGrid columns={5}>
|
||||
<StatCard
|
||||
title={t("fieldMasteredRate")}
|
||||
value={formatRate(stats.masteredRate)}
|
||||
title={t("fieldTotal")}
|
||||
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
|
||||
title={t("fieldToReview")}
|
||||
value={String(stats.toReview)}
|
||||
description={t("fieldToReviewDesc")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 错题列表表格。 */
|
||||
function ErrorBookTable({
|
||||
function ErrorBookCardGrid({
|
||||
items,
|
||||
onMarkMastered,
|
||||
onViewDetail,
|
||||
marking,
|
||||
}: {
|
||||
items: StudentErrorBookItem[];
|
||||
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;
|
||||
}): React.ReactElement {
|
||||
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("colStatus")}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -270,15 +628,19 @@ function ErrorBookTable({
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30">
|
||||
<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}
|
||||
</p>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{item.subject}</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={
|
||||
item.errorCount >= 3 ? "text-destructive font-semibold" : ""
|
||||
item.errorCount >= 3 ? "font-semibold text-destructive" : ""
|
||||
}
|
||||
>
|
||||
{item.errorCount}
|
||||
@@ -291,18 +653,31 @@ function ErrorBookTable({
|
||||
<ErrorBookStatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{item.source}
|
||||
{sourceOptionLabel(item.source, t)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={marking || item.status === "mastered"}
|
||||
onClick={() => void onMarkMastered(item.id)}
|
||||
>
|
||||
{t("markMastered")}
|
||||
</Button>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={marking || item.status === "mastered"}
|
||||
onClick={() => void onMarkMastered(item.id)}
|
||||
>
|
||||
{t("markMastered")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -311,52 +686,3 @@ function ErrorBookTable({
|
||||
</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");
|
||||
}
|
||||
|
||||
@@ -2,27 +2,15 @@
|
||||
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生考试作答工作台 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - studentExamTake(id) ❌ schema 无此根字段 → MSW 兜底
|
||||
* - submitStudentExam(id, answers) mutation ❌ → MSW 兜底
|
||||
*
|
||||
* 三栏布局(WorkbenchPageShell):
|
||||
* - left:题目导航(题号 / 已答 / 未答 / 已标记 状态)
|
||||
* - center:当前题目作答区(单选 / 多选 / 填空 / 简答)
|
||||
* - right:倒计时 + 提交按钮 + 作答统计
|
||||
*
|
||||
* 答案本地暂存:localStorage key `exam-answers-${id}`,防刷新丢失
|
||||
* 倒计时:基于 duration(分钟),到 0 自动提交
|
||||
* 提交确认:window.confirm 二次确认
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading / 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 {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Flag,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 {
|
||||
const t = useTranslations("studentDomain.exams.take");
|
||||
@@ -59,7 +49,6 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentExamTake(examId);
|
||||
const submitMutation = useSubmitStudentExam();
|
||||
|
||||
@@ -68,17 +57,19 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
const [currentNo, setCurrentNo] = useState<number>(1);
|
||||
const [secondsLeft, setSecondsLeft] = useState<number>(0);
|
||||
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 submittedRef = useRef<boolean>(false);
|
||||
|
||||
// 首次拿到数据时初始化倒计时
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setSecondsLeft((prev) => (prev > 0 ? prev : data.duration * 60));
|
||||
}, [data]);
|
||||
|
||||
// 从 localStorage 恢复暂存答案(仅客户端执行)
|
||||
useEffect(() => {
|
||||
if (!examId) return;
|
||||
try {
|
||||
@@ -88,21 +79,28 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
setAnswers(parsed.answers ?? {});
|
||||
setMarked(parsed.marked ?? []);
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用或 JSON 损坏:忽略,从空开始
|
||||
} catch (err) {
|
||||
console.warn("[portal-shell] restore exam answers failed:", err);
|
||||
}
|
||||
setHydrated(true);
|
||||
}, [examId, storageKey]);
|
||||
|
||||
// 答案变化时写入 localStorage(防刷新丢失)
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
try {
|
||||
const payload: PersistedState = { answers, marked };
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
} catch {
|
||||
// 写入失败:静默忽略,不影响作答
|
||||
}
|
||||
setAutoSaveStatus("saving");
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
const payload: PersistedState = { answers, marked };
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
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]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
@@ -110,8 +108,7 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
if (submittedRef.current) return;
|
||||
if (!data) return;
|
||||
if (!isAuto) {
|
||||
const confirmed = window.confirm(t("submitConfirm"));
|
||||
if (!confirmed) return;
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
submittedRef.current = true;
|
||||
try {
|
||||
@@ -119,8 +116,8 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
notify.success(t("submitSuccess"));
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// 清理失败不影响流程
|
||||
} catch (err) {
|
||||
console.warn("[portal-shell] clear exam answers failed:", err);
|
||||
}
|
||||
router.push(`/shell/student/exams/${examId}/result`);
|
||||
} catch (err) {
|
||||
@@ -132,7 +129,6 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
[answers, data, examId, router, storageKey, submitMutation, t],
|
||||
);
|
||||
|
||||
// 倒计时:每秒递减,归零自动提交
|
||||
useEffect(() => {
|
||||
if (!data || secondsLeft <= 0) return;
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -140,6 +136,7 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
if (prev <= 1) {
|
||||
window.clearInterval(timer);
|
||||
if (!submittedRef.current) {
|
||||
notify.warning(t("timeUpAutoSubmit"));
|
||||
void handleSubmit(true);
|
||||
}
|
||||
return 0;
|
||||
@@ -150,7 +147,7 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
return (): void => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [data, secondsLeft, handleSubmit]);
|
||||
}, [data, secondsLeft, handleSubmit, t]);
|
||||
|
||||
const questions = data?.questions ?? [];
|
||||
|
||||
@@ -158,12 +155,11 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
const answeredCount = questions.filter(
|
||||
(q) => answers[String(q.questionNo)]?.trim().length,
|
||||
).length;
|
||||
const markedCount = marked.length;
|
||||
return {
|
||||
total: questions.length,
|
||||
answered: answeredCount,
|
||||
unanswered: questions.length - answeredCount,
|
||||
marked: markedCount,
|
||||
marked: marked.length,
|
||||
};
|
||||
}, [questions, answers, marked]);
|
||||
|
||||
@@ -185,77 +181,91 @@ export function StudentExamTakeClient(): React.ReactElement {
|
||||
const currentQuestion = questions.find((q) => q.questionNo === currentNo);
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode ?? notFoundNode}
|
||||
left={
|
||||
data ? (
|
||||
<QuestionNavPanel
|
||||
questions={questions}
|
||||
currentNo={currentNo}
|
||||
answers={answers}
|
||||
marked={marked}
|
||||
onSelect={setCurrentNo}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
center={
|
||||
data && currentQuestion ? (
|
||||
<QuestionAnswerPanel
|
||||
question={currentQuestion}
|
||||
answer={answers[String(currentQuestion.questionNo)] ?? ""}
|
||||
marked={marked.includes(currentQuestion.questionNo)}
|
||||
onAnswerChange={(v) =>
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[String(currentQuestion.questionNo)]: v,
|
||||
}))
|
||||
}
|
||||
onToggleMark={() => {
|
||||
setMarked((prev) =>
|
||||
prev.includes(currentQuestion.questionNo)
|
||||
? prev.filter((n) => n !== currentQuestion.questionNo)
|
||||
: [...prev, currentQuestion.questionNo],
|
||||
);
|
||||
}}
|
||||
onPrev={() => {
|
||||
const prevNo = currentQuestion.questionNo - 1;
|
||||
if (prevNo >= 1) setCurrentNo(prevNo);
|
||||
}}
|
||||
onNext={() => {
|
||||
const nextNo = currentQuestion.questionNo + 1;
|
||||
if (nextNo <= questions.length) setCurrentNo(nextNo);
|
||||
}}
|
||||
hasPrev={currentQuestion.questionNo > 1}
|
||||
hasNext={currentQuestion.questionNo < questions.length}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data ? (
|
||||
<TimerSubmitPanel
|
||||
secondsLeft={secondsLeft}
|
||||
stats={stats}
|
||||
submitting={submitMutation.loading}
|
||||
onSubmit={() => void handleSubmit(false)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<WorkbenchPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
description={
|
||||
data ? `${t("totalScore")}: ${data.totalScore}` : undefined
|
||||
}
|
||||
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}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode ?? notFoundNode}
|
||||
left={
|
||||
data ? (
|
||||
<QuestionNavPanel
|
||||
questions={questions}
|
||||
currentNo={currentNo}
|
||||
answers={answers}
|
||||
marked={marked}
|
||||
onSelect={setCurrentNo}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
center={
|
||||
data && currentQuestion ? (
|
||||
<QuestionAnswerPanel
|
||||
question={currentQuestion}
|
||||
answer={answers[String(currentQuestion.questionNo)] ?? ""}
|
||||
marked={marked.includes(currentQuestion.questionNo)}
|
||||
onAnswerChange={(v) =>
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[String(currentQuestion.questionNo)]: v,
|
||||
}))
|
||||
}
|
||||
onToggleMark={() => {
|
||||
setMarked((prev) =>
|
||||
prev.includes(currentQuestion.questionNo)
|
||||
? prev.filter((n) => n !== currentQuestion.questionNo)
|
||||
: [...prev, currentQuestion.questionNo],
|
||||
);
|
||||
}}
|
||||
onPrev={() => {
|
||||
const prevNo = currentQuestion.questionNo - 1;
|
||||
if (prevNo >= 1) setCurrentNo(prevNo);
|
||||
}}
|
||||
onNext={() => {
|
||||
const nextNo = currentQuestion.questionNo + 1;
|
||||
if (nextNo <= questions.length) setCurrentNo(nextNo);
|
||||
}}
|
||||
hasPrev={currentQuestion.questionNo > 1}
|
||||
hasNext={currentQuestion.questionNo < questions.length}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data ? (
|
||||
<TimerSubmitPanel
|
||||
secondsLeft={secondsLeft}
|
||||
stats={stats}
|
||||
submitting={submitMutation.loading}
|
||||
autoSaveStatus={autoSaveStatus}
|
||||
onSubmit={() => setConfirmOpen(true)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{confirmOpen ? (
|
||||
<ConfirmDialog
|
||||
unansweredCount={stats.unanswered}
|
||||
isBusy={submitMutation.loading}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={() => void handleSubmit(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左栏:题目导航面板。
|
||||
*
|
||||
* 题号网格,每格显示状态:
|
||||
* - 当前题:边框高亮
|
||||
* - 已答:背景填充
|
||||
* - 已标记:右上角小标
|
||||
*/
|
||||
function QuestionNavPanel({
|
||||
questions,
|
||||
currentNo,
|
||||
@@ -271,12 +281,15 @@ function QuestionNavPanel({
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.exams.take");
|
||||
const markedSet = new Set(marked);
|
||||
const answeredCount = questions.filter(
|
||||
(q) => (answers[String(q.questionNo)] ?? "").trim().length > 0,
|
||||
).length;
|
||||
return (
|
||||
<WorkbenchPanel
|
||||
title={t("questionNav")}
|
||||
actions={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("answered")} {Object.keys(answers).length} / {questions.length}
|
||||
{t("answered")} {answeredCount} / {questions.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -297,7 +310,7 @@ function QuestionNavPanel({
|
||||
type="button"
|
||||
onClick={() => onSelect(q.questionNo)}
|
||||
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}
|
||||
>
|
||||
{q.questionNo}
|
||||
@@ -315,11 +328,6 @@ function QuestionNavPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中栏:当前题目作答面板。
|
||||
*
|
||||
* 含:题号 / 题干 / 分值 / 答题区(按题型) / 上一题/下一题 / 标记按钮。
|
||||
*/
|
||||
function QuestionAnswerPanel({
|
||||
question,
|
||||
answer,
|
||||
@@ -344,7 +352,7 @@ function QuestionAnswerPanel({
|
||||
const t = useTranslations("studentDomain.exams.take");
|
||||
return (
|
||||
<WorkbenchPanel
|
||||
title={`${question.questionNo}.`}
|
||||
title={`${question.questionNo}. ${t("questionUnit")}`}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -361,7 +369,8 @@ function QuestionAnswerPanel({
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{question.question}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{question.type} · {question.score}
|
||||
{t("questionType")}: {question.type} · {t("score")}:{" "}
|
||||
{question.score}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -378,9 +387,10 @@ function QuestionAnswerPanel({
|
||||
size="sm"
|
||||
onClick={onPrev}
|
||||
disabled={!hasPrev}
|
||||
aria-label="previous"
|
||||
aria-label={t("prevQuestion")}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t("prevQuestion")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p>
|
||||
<Button
|
||||
@@ -389,8 +399,9 @@ function QuestionAnswerPanel({
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={!hasNext}
|
||||
aria-label="next"
|
||||
aria-label={t("nextQuestion")}
|
||||
>
|
||||
{t("nextQuestion")}
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -399,14 +410,6 @@ function QuestionAnswerPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按题型渲染对应答题输入。
|
||||
*
|
||||
* - single_choice:单选(radio)
|
||||
* - multiple_choice:多选(checkbox,逗号分隔)
|
||||
* - fill_blank:填空(text input)
|
||||
* - essay:简答(textarea)
|
||||
*/
|
||||
function AnswerInput({
|
||||
question,
|
||||
answer,
|
||||
@@ -445,27 +448,15 @@ function AnswerInput({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === "essay") {
|
||||
return (
|
||||
<textarea
|
||||
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={answer}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
<textarea
|
||||
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={answer}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单选输入:从 options 中选一项,answer 为选项索引(字符串)。
|
||||
*/
|
||||
function SingleChoiceInput({
|
||||
options,
|
||||
answer,
|
||||
@@ -499,9 +490,6 @@ function SingleChoiceInput({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多选输入:可勾选多项,answer 为逗号分隔的索引字符串(升序去重)。
|
||||
*/
|
||||
function MultipleChoiceInput({
|
||||
options,
|
||||
answer,
|
||||
@@ -554,13 +542,11 @@ function MultipleChoiceInput({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏:倒计时 + 作答统计 + 提交按钮。
|
||||
*/
|
||||
function TimerSubmitPanel({
|
||||
secondsLeft,
|
||||
stats,
|
||||
submitting,
|
||||
autoSaveStatus,
|
||||
onSubmit,
|
||||
}: {
|
||||
secondsLeft: number;
|
||||
@@ -571,25 +557,62 @@ function TimerSubmitPanel({
|
||||
marked: number;
|
||||
};
|
||||
submitting: boolean;
|
||||
autoSaveStatus: "idle" | "saving" | "saved";
|
||||
onSubmit: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.exams.take");
|
||||
const timeStr = formatTime(secondsLeft);
|
||||
const urgent = secondsLeft > 0 && secondsLeft <= 60;
|
||||
const expired = secondsLeft <= 0;
|
||||
return (
|
||||
<WorkbenchPanel title={t("timeRemaining")}>
|
||||
<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
|
||||
className={`font-mono text-2xl font-semibold ${
|
||||
urgent ? "text-destructive" : "text-foreground"
|
||||
urgent || expired ? "text-destructive" : "text-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{timeStr}
|
||||
</p>
|
||||
</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="flex justify-between">
|
||||
<span className="text-muted-foreground">{t("answered")}</span>
|
||||
@@ -616,15 +639,57 @@ function TimerSubmitPanel({
|
||||
{t("submit")}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("makeSureAnswered")}</p>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将秒数格式化为 HH:MM:SS。
|
||||
*/
|
||||
function ConfirmDialog({
|
||||
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 {
|
||||
const safe = Math.max(0, Math.floor(totalSeconds));
|
||||
const h = Math.floor(safe / 3600);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// @contract-pending:studentExams 查询契约待补齐,全 MSW 兜底
|
||||
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生考试列表页 - 客户端组件(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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
@@ -17,8 +20,7 @@
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useStudentExams, type StudentExam } from "@/lib/api";
|
||||
@@ -27,49 +29,74 @@ import {
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
"all",
|
||||
"upcoming",
|
||||
"inProgress",
|
||||
"ended",
|
||||
"scored",
|
||||
] as const;
|
||||
type StatusFilter = (typeof STATUS_OPTIONS)[number];
|
||||
/** 状态分组配置(顺序即展示顺序)。 */
|
||||
const STATUS_GROUPS = ["inProgress", "upcoming", "ended", "scored"] as const;
|
||||
type StatusGroup = (typeof STATUS_GROUPS)[number];
|
||||
|
||||
/** 将后端 status 归一到分组 key。 */
|
||||
function toGroup(status: string): StatusGroup {
|
||||
const s = status.toLowerCase();
|
||||
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> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function StudentExamsListClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.exams.list");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const statusParam = searchParams.get("status") ?? "all";
|
||||
const statusFilter: StatusFilter = (
|
||||
STATUS_OPTIONS as readonly string[]
|
||||
).includes(statusParam)
|
||||
? (statusParam as StatusFilter)
|
||||
: "all";
|
||||
// @contract-pending:MSW 兜底,不传 status 取全量后客户端分组
|
||||
const { data, loading, error } = useStudentExams({});
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentExams(
|
||||
statusFilter === "all" ? {} : { status: statusFilter },
|
||||
);
|
||||
const exams = data ?? [];
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value && value !== "all") {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
// 学科选项(去重,保留出现顺序)
|
||||
const subjectOptions = useMemo<string[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const list: string[] = [];
|
||||
for (const e of exams) {
|
||||
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 ? (
|
||||
<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")}
|
||||
icon={<FileText className="size-6" />}
|
||||
filters={
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
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>
|
||||
<SubjectFilterPills
|
||||
options={subjectOptions}
|
||||
disabled={exams.length === 0}
|
||||
/>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={(data?.length ?? 0) === 0 && !loading}
|
||||
emptyNode={
|
||||
<div className="rounded-xl border p-10 text-center text-muted-foreground">
|
||||
{t("emptyTitle")}
|
||||
</div>
|
||||
}
|
||||
loadingNode={<ListPageSkeleton rows={4} />}
|
||||
empty={!loading && !error && exams.length === 0}
|
||||
emptyNode={<ExamsEmptyState />}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function ExamsTable({ items }: { items: StudentExam[] }): React.ReactElement {
|
||||
/** 学科筛选 pill 按钮组(客户端状态由 URL 管理,此处仅展示按钮组容器)。 */
|
||||
function SubjectFilterPills({
|
||||
options,
|
||||
disabled,
|
||||
}: {
|
||||
options: string[];
|
||||
disabled: boolean;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.exams.list");
|
||||
if (disabled) return <span className="text-xs text-muted-foreground" />;
|
||||
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("colTitle")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colExamDate")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colDuration")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colLocation")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colSeatNo")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("colScore")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
role="group"
|
||||
aria-label={t("subjectFilter")}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("subjectFilter")}:
|
||||
</span>
|
||||
{options.map((subj) => (
|
||||
<span
|
||||
key={subj}
|
||||
className="inline-flex h-7 items-center rounded-full border bg-background px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{subj}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按考试状态渲染对应操作链接:
|
||||
* - upcoming → 进入考试(跳 take)
|
||||
* - 其他(进行中/已结束/已出分)→ 查看结果(跳 result)
|
||||
*/
|
||||
function renderAction(exam: StudentExam): React.ReactElement {
|
||||
/** 按状态分组的考试卡片组。 */
|
||||
function ExamStatusGroup({
|
||||
group,
|
||||
items,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
items: StudentExam[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.exams.list");
|
||||
if (exam.status === "upcoming") {
|
||||
return (
|
||||
<Link
|
||||
href={`/shell/student/exams/${exam.id}/take`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("takeExam")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<section aria-label={label}>
|
||||
<div className="mb-3 flex items-baseline gap-3">
|
||||
<h2 className="text-lg font-semibold">{label}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{items.length}{" "}
|
||||
{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 (
|
||||
<Link
|
||||
href={`/shell/student/exams/${exam.id}/result`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
href={linkHref}
|
||||
className="block h-full focus-visible:outline-2"
|
||||
aria-label={`${exam.title} - ${isTakeable ? t("takeExam") : t("viewResult")}`}
|
||||
>
|
||||
{t("viewResult")}
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生考试状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function ExamStatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
/** 考试状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。 */
|
||||
function examStatusBadgeClass(group: StatusGroup): string {
|
||||
switch (group) {
|
||||
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 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 (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex min-h-80 flex-col items-center justify-center gap-2 rounded-xl border p-8 text-center">
|
||||
<p className="text-lg font-semibold">{t("emptyTitle")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("emptyDesc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,22 @@
|
||||
* - studentGrades(subject, type, q):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单: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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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 { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
@@ -21,28 +30,51 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
import { useStudentGrades, type StudentGrade } from "@/lib/api";
|
||||
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 { 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 {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} 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,待后端契约补齐后改为动态拉取) */
|
||||
const SUBJECT_OPTIONS = [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
"历史",
|
||||
"地理",
|
||||
"政治",
|
||||
/** 学科枚举键(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||
const SUBJECT_OPTION_KEYS = [
|
||||
"chinese",
|
||||
"math",
|
||||
"english",
|
||||
"physics",
|
||||
"chemistry",
|
||||
"biology",
|
||||
"history",
|
||||
"geography",
|
||||
"politics",
|
||||
] as const;
|
||||
|
||||
/** 成绩类型枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||
const TYPE_OPTIONS = ["考试", "作业", "测验", "综合"] as const;
|
||||
/** 成绩类型枚举键(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||
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> 中
|
||||
@@ -57,6 +89,7 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
|
||||
const subject = searchParams.get("subject") ?? "";
|
||||
const type = searchParams.get("type") ?? "";
|
||||
const semester = searchParams.get("semester") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
@@ -66,9 +99,26 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
// 数据契约:data 为 StudentGradesResult(items + 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[]>(() => {
|
||||
return data ?? [];
|
||||
}, [data]);
|
||||
if (!items) return [];
|
||||
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 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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -91,7 +147,13 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = <EmptyState icon={Award} title={t("emptyTitle")} />;
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={Award}
|
||||
title={t("emptyTitle")}
|
||||
description={t("emptyDescription")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
@@ -99,7 +161,7 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
description={t("description")}
|
||||
icon={<Award className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterBar variant="wrap">
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
@@ -112,9 +174,9 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
aria-label={t("subjectFilter")}
|
||||
>
|
||||
<option value="">{t("allSubjects")}</option>
|
||||
{SUBJECT_OPTIONS.map((s) => (
|
||||
{SUBJECT_OPTION_KEYS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
{t(`subjects.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -125,13 +187,37 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
aria-label={t("typeFilter")}
|
||||
>
|
||||
<option value="">{t("allTypes")}</option>
|
||||
{TYPE_OPTIONS.map((tp) => (
|
||||
{TYPE_OPTION_KEYS.map((tp) => (
|
||||
<option key={tp} value={tp}>
|
||||
{tp}
|
||||
{t(`types.${tp}`)}
|
||||
</option>
|
||||
))}
|
||||
</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}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
@@ -144,11 +230,170 @@ export function StudentGradesListClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StudentGradesTable items={filteredItems} />
|
||||
<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} />
|
||||
</div>
|
||||
</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 排版规范)。
|
||||
*/
|
||||
@@ -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 位小数。 */
|
||||
function formatScore(score: number): string {
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
|
||||
@@ -4,37 +4,66 @@
|
||||
* 学生成绩报告卡页 - 客户端组件(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
|
||||
*
|
||||
* 视图结构:
|
||||
* 1. 学年/学期切换器(URL 状态)
|
||||
* 2. 面包屑导航(返回成绩列表)
|
||||
* 3. A4 报告卡容器(.report-card-a4,打印时仅此区域可见)
|
||||
* - 学校抬头 / 学期信息
|
||||
* - 学生基本信息区(姓名 / 班级 / 班主任 / 生成时间)
|
||||
* - 各科成绩明细表(rowSpan 合并同学科单元格 + 学科排名 / 平均分)
|
||||
* - 综合统计(总平均分 / 总排名 / 及格率 / 优秀率)
|
||||
* - 教师评语
|
||||
* - 签名区
|
||||
* 4. 打印动作(window.print,含 loading 状态与错误兜底)
|
||||
*
|
||||
* URL 状态:?academicYearId=xxx &semester=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级 + EmptyState)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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 { useTransition } from "react";
|
||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type {
|
||||
StudentReportCard,
|
||||
StudentReportCardRecord,
|
||||
StudentReportCardSubject,
|
||||
} from "@/lib/api";
|
||||
import { useStudentReportCard } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} 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;
|
||||
|
||||
/** 学期枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||
/** 学期选项(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||
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 强制)。
|
||||
*/
|
||||
export function StudentReportCardClient(): React.ReactElement {
|
||||
@@ -43,6 +72,7 @@ export function StudentReportCardClient(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
const academicYearId = searchParams.get("academicYearId") ?? "";
|
||||
const semester = searchParams.get("semester") ?? "";
|
||||
@@ -53,6 +83,8 @@ export function StudentReportCardClient(): React.ReactElement {
|
||||
semester: semester || undefined,
|
||||
});
|
||||
|
||||
const summary = useMemo(() => computeSummary(data?.subjects ?? []), [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
@@ -65,24 +97,84 @@ export function StudentReportCardClient(): React.ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrint = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.print();
|
||||
const handlePrint = useCallback((): void => {
|
||||
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();
|
||||
} 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 ? (
|
||||
<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;
|
||||
if (loading) {
|
||||
return <DetailPageSkeleton />;
|
||||
}
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState icon={FileText} title={t("emptyTitle")} />
|
||||
) : undefined;
|
||||
if (error) {
|
||||
return (
|
||||
<DetailPageShell
|
||||
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 (
|
||||
<DetailPageShell
|
||||
@@ -91,223 +183,555 @@ export function StudentReportCardClient(): React.ReactElement {
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/student/grades"
|
||||
actions={
|
||||
<Button onClick={handlePrint} disabled={!data}>
|
||||
{t("print")}
|
||||
<Button variant="outline" onClick={handlePrint} disabled={isPrinting}>
|
||||
<Printer className="mr-2 size-4" />
|
||||
{isPrinting ? t("preparing") : t("print")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ReportCardBody data={data} /> : null}
|
||||
<ReportCardFilters
|
||||
academicYearId={academicYearId}
|
||||
semester={semester}
|
||||
onAcademicYearChange={(v) => updateQuery("academicYearId", v)}
|
||||
onSemesterChange={(v) => updateQuery("semester", v)}
|
||||
{/* 学年/学期切换器(打印时隐藏) */}
|
||||
<DetailSection title={t("periodSelectorTitle")}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm text-muted-foreground">
|
||||
{t("academicYearLabel")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告卡筛选器(学年/学期切换)。
|
||||
*/
|
||||
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({
|
||||
/** A4 报告卡容器:学校抬头 + 学生信息 + 学科明细 + 汇总 + 评语 + 签名。 */
|
||||
function ReportCardA4({
|
||||
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,
|
||||
semester,
|
||||
summary,
|
||||
}: {
|
||||
subjects: NonNullable<
|
||||
ReturnType<typeof useStudentReportCard>["data"]
|
||||
>["subjects"];
|
||||
data: StudentReportCard;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
summary: ReportCardSummary;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.grades.reportCard");
|
||||
if (subjects.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t("emptyTitle")}</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">{t("colSubject")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("colScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("colTotalScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("colScoreRate")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("colLevel")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("colTeacherComment")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subjects.map((entry, idx) => (
|
||||
<tr
|
||||
key={`${entry.subject}-${idx}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4 font-medium">{entry.subject}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-semibold">
|
||||
{formatScore(entry.score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{formatScore(entry.totalScore)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={scoreRateToColorClass(entry.scoreRate)}>
|
||||
{formatRate(entry.scoreRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={levelToColorClass(entry.level)}>
|
||||
{entry.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
||||
{entry.teacherComment || "--"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{academicYear || semester ? (
|
||||
<tfoot>
|
||||
<tr className="border-t text-xs text-muted-foreground">
|
||||
<td className="py-2 pr-4" colSpan={6}>
|
||||
{academicYear
|
||||
? `${t("academicYearLabel")}: ${academicYear}`
|
||||
: ""}
|
||||
{academicYear && semester ? " / " : ""}
|
||||
{semester ? `${t("semesterLabel")}: ${semester}` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
) : null}
|
||||
</table>
|
||||
<div className="report-card-a4 mx-auto w-full max-w-3xl rounded-lg border-2 bg-white p-8 shadow-sm">
|
||||
{/* 学校抬头 */}
|
||||
<header className="border-b-2 border-black pb-3 text-center">
|
||||
<h1 className="text-2xl font-bold">{t("reportCardTitle")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("periodLabel", { year: academicYear || "--", semester })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 学生基本信息 */}
|
||||
<section className="report-card-student-info mt-4">
|
||||
<h2 className="mb-2 text-base font-semibold">
|
||||
{t("studentInfoTitle")}
|
||||
</h2>
|
||||
<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">
|
||||
<InfoField
|
||||
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")}
|
||||
>
|
||||
<h2 className="mb-2 text-base font-semibold">{t("commentsTitle")}</h2>
|
||||
<div className="space-y-2">
|
||||
{data.subjects.filter((s) => s.teacherComment).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("commentsPlaceholder")}
|
||||
</p>
|
||||
) : (
|
||||
data.subjects
|
||||
.filter((s) => s.teacherComment)
|
||||
.map((s) => (
|
||||
<div
|
||||
key={s.subject}
|
||||
className="rounded-md border bg-muted/30 p-2 text-sm"
|
||||
>
|
||||
<p className="font-medium">{s.subject}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{s.teacherComment}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 签名区 */}
|
||||
<section className="report-card-signatures mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<SignatureBlock label={t("signatureClassTeacher")} />
|
||||
<SignatureBlock label={t("signatureParent")} />
|
||||
<SignatureBlock label={t("signaturePrincipal")} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t("footerNote", { date: formatDateTime(new Date().toISOString()) })}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 格式化分数为展示字符串,保留 1 位小数。 */
|
||||
function formatScore(score: number): string {
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
/** 学科成绩明细表:使用 rowSpan 合并同学科单元格,展示多记录展开。 */
|
||||
function GradesTable({
|
||||
subjects,
|
||||
}: {
|
||||
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 的得分率为百分比字符串。 */
|
||||
function formatRate(rate: number): string {
|
||||
/** 单行渲染:根据 rowSpan 标记决定是否渲染学科汇总单元格。 */
|
||||
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 "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** 根据得分率返回 Tailwind 文本语义类名。 */
|
||||
function scoreRateToColorClass(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (rate >= 0.8) return "text-emerald-600";
|
||||
if (rate >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
/** 格式化排名为 "{rank} / {total}"。 */
|
||||
function formatRank(
|
||||
rank: number | undefined,
|
||||
total: number | undefined,
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
if (rank == null || total == null) return "--";
|
||||
return t("rankFormat", { rank, total });
|
||||
}
|
||||
|
||||
/** 根据等级返回 Tailwind 文本语义类名。 */
|
||||
function levelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
/** 将记录类型映射为 i18n 文案。 */
|
||||
function translateRecordType(
|
||||
type: string,
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
): string {
|
||||
switch (type) {
|
||||
case "exam":
|
||||
return t("typeExam");
|
||||
case "homework":
|
||||
return t("typeHomework");
|
||||
case "quiz":
|
||||
return t("typeQuiz");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -7,22 +7,39 @@
|
||||
* - studentHomework(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* URL 状态:?status=xxx
|
||||
* 本地状态:viewMode(card/table)、searchQ(搜索词)
|
||||
*
|
||||
* 视图:
|
||||
* - 卡片视图:网格卡片,展示标题/学科/截止/状态/尝试次数/最新成绩/操作
|
||||
* - 表格视图:保留原学科分组表格
|
||||
*
|
||||
* 子区域:每个学科下分"未答/已答"两组
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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 { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useStudentHomework, type StudentHomework } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
type ViewMode = "card" | "table";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -36,20 +53,25 @@ export function StudentHomeworkListClient(): React.ReactElement {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
||||
const [searchQ, setSearchQ] = useState<string>("");
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentHomework({
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
// 客户端二次筛选(status)—— 后端补齐契约后改服务端筛选
|
||||
// 客户端二次筛选(status + search)—— 后端补齐契约后改服务端筛选
|
||||
const filteredItems = useMemo<StudentHomework[]>(() => {
|
||||
const items = data ?? [];
|
||||
if (!statusFilter) return items;
|
||||
return items.filter(
|
||||
(item) => normalizeStatus(item.status) === statusFilter,
|
||||
);
|
||||
}, [data, statusFilter]);
|
||||
const q = searchQ.trim().toLowerCase();
|
||||
return items.filter((item) => {
|
||||
const matchStatus =
|
||||
!statusFilter || normalizeStatus(item.status) === statusFilter;
|
||||
const matchSearch = !q || item.title.toLowerCase().includes(q);
|
||||
return matchStatus && matchSearch;
|
||||
});
|
||||
}, [data, statusFilter, searchQ]);
|
||||
|
||||
// 按学科分组
|
||||
const grouped = useMemo<Map<string, StudentHomework[]>>(() => {
|
||||
@@ -83,31 +105,47 @@ export function StudentHomeworkListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const filtersNode = (
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={searchQ}
|
||||
onChange={setSearchQ}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("statusFilter")}
|
||||
>
|
||||
<option value="">{t("allStatus")}</option>
|
||||
<option value="pending">{t("statusPending")}</option>
|
||||
<option value="submitted">{t("statusSubmitted")}</option>
|
||||
<option value="graded">{t("statusGraded")}</option>
|
||||
<option value="overdue">{t("statusOverdue")}</option>
|
||||
</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={
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("statusFilter")}
|
||||
>
|
||||
<option value="">{t("allStatus")}</option>
|
||||
<option value="pending">{t("statusPending")}</option>
|
||||
<option value="submitted">{t("statusSubmitted")}</option>
|
||||
<option value="graded">{t("statusGraded")}</option>
|
||||
</select>
|
||||
}
|
||||
filters={filtersNode}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
empty={showEmpty && !searchQ && !statusFilter}
|
||||
emptyNode={
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<p className="text-lg font-semibold">{t("emptyTitle")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("emptyTitle")}
|
||||
description={t("emptyDesc")}
|
||||
/>
|
||||
}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
@@ -116,87 +154,349 @@ export function StudentHomeworkListClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{Array.from(grouped.entries()).map(([subject, items]) => (
|
||||
<HomeworkSubjectGroup key={subject} subject={subject} items={items} />
|
||||
))}
|
||||
</div>
|
||||
{showEmpty && (searchQ || statusFilter) ? (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title={t("noResults")}
|
||||
description={t("emptyDesc")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasResults ? (
|
||||
<div className="space-y-6">
|
||||
{Array.from(grouped.entries()).map(([subject, items]) => (
|
||||
<HomeworkSubjectGroup
|
||||
key={subject}
|
||||
subject={subject}
|
||||
items={items}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</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({
|
||||
subject,
|
||||
items,
|
||||
viewMode,
|
||||
}: {
|
||||
subject: string;
|
||||
items: StudentHomework[];
|
||||
viewMode: ViewMode;
|
||||
}): React.ReactElement {
|
||||
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 (
|
||||
<section className="rounded-xl border bg-card">
|
||||
<header className="border-b bg-muted/30 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">{subject}</h2>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<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("colStatus")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("colScore")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((hw) => (
|
||||
<tr key={hw.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/student/homework/${hw.id}/submit`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{hw.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">{hw.dueDate}</td>
|
||||
<td className="p-3">
|
||||
<HomeworkStatusBadge status={hw.status} />
|
||||
</td>
|
||||
<td className="p-3 text-right font-mono">
|
||||
{hw.score !== null ? hw.score : "--"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link
|
||||
href={`/shell/student/homework/${hw.id}/submit`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("submitHomework")}
|
||||
</Link>
|
||||
{normalizeStatus(hw.status) === "graded" ? (
|
||||
<Link
|
||||
href={`/shell/student/homework/${hw.id}/analysis`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewAnalysis")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
<thead className="border-b bg-muted/30 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<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("attempts")}</th>
|
||||
<th className="p-3 font-medium">{t("colStatus")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("latestScore")}</th>
|
||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((hw) => (
|
||||
<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">
|
||||
<Link href={submitHref} className="font-medium hover:underline">
|
||||
{hw.title}
|
||||
</Link>
|
||||
</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">
|
||||
<HomeworkStatusBadge status={hw.status} />
|
||||
</td>
|
||||
<td className="p-3 text-right font-mono">
|
||||
{hw.latestScore !== null ? hw.latestScore : "--"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link
|
||||
href={isGraded ? analysisHref : submitHref}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
{isGraded ? (
|
||||
<Link
|
||||
href={analysisHref}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewAnalysis")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
@@ -205,39 +505,47 @@ function HomeworkStatusBadge({
|
||||
}: {
|
||||
status: string;
|
||||
}): 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 (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{status}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态归一化(兼容 MSW 中文与未来英文枚举)。
|
||||
* pending / submitted / graded / overdue
|
||||
* pending / submitted / graded / overdue / in_progress / not_started
|
||||
*/
|
||||
function normalizeStatus(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (s.includes("pend") || s.includes("待提交")) return "pending";
|
||||
if (s.includes("submit") || s.includes("已提交")) return "submitted";
|
||||
if (s.includes("overdue") || s.includes("逾期")) return "overdue";
|
||||
if (s.includes("grad") || s.includes("已完成") || s.includes("已批改")) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。
|
||||
*/
|
||||
function homeworkStatusToBadgeClass(status: string): string {
|
||||
const normalized = normalizeStatus(status);
|
||||
function homeworkStatusToBadgeClass(normalized: string): string {
|
||||
switch (normalized) {
|
||||
case "not_started":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "pending":
|
||||
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":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
|
||||
case "graded":
|
||||
@@ -248,3 +556,29 @@ function homeworkStatusToBadgeClass(status: string): string {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* 学生作业作答工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - studentHomeworkSubmit(id) ❌ → MSW 兜底
|
||||
* - submitStudentHomework(input) mutation ❌ → MSW 兜底
|
||||
* - studentHomeworkSubmit(id) -> MSW 兜底
|
||||
* - submitStudentHomework(input) mutation -> MSW 兜底
|
||||
* - studentHomeworkAnalysis(id) -> MSW 兜底(ReviewView 复用)
|
||||
*
|
||||
* 三栏布局(WorkbenchPageShell):
|
||||
* - left:题目导航(题号 + 答题状态,点击跳转)
|
||||
* - center:题目列表 + 答案输入区
|
||||
* - right:自动保存提示 + 答题统计 + 提交按钮(二次确认)
|
||||
* 状态分流(依据 submissionStatus):
|
||||
* - not_started / in_progress / undefined -> 作答工作台(TakeView):三栏布局
|
||||
* - submitted / graded -> 只读复盘视图(ReviewView):详情页布局
|
||||
*
|
||||
* 答案本地暂存:localStorage key `homework-answers-${id}`,防刷新丢失。
|
||||
*
|
||||
@@ -18,25 +18,55 @@
|
||||
*
|
||||
* 关联: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 { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
useStudentHomeworkAnalysis,
|
||||
useStudentHomeworkSubmit,
|
||||
useSubmitStudentHomework,
|
||||
type StudentHomeworkAnalysis,
|
||||
type StudentHomeworkAnalysisQuestion,
|
||||
type StudentHomeworkSubmit,
|
||||
type StudentHomeworkSubmitQuestion,
|
||||
} from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPageSkeleton,
|
||||
WorkbenchPanel,
|
||||
} 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> 中。
|
||||
*/
|
||||
@@ -46,29 +76,61 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
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 [confirmOpen, setConfirmOpen] = useState<boolean>(false);
|
||||
const [activeQuestion, setActiveQuestion] = useState<number>(1);
|
||||
const initialized = useRef<boolean>(false);
|
||||
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
|
||||
const storageKey = `homework-answers-${homeworkId}`;
|
||||
|
||||
// 首次拿到数据时初始化答案(localStorage 优先,回退到后端已存答案)
|
||||
useEffect(() => {
|
||||
if (!data || initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
setAnswers(JSON.parse(saved) as Record<string, string>);
|
||||
return;
|
||||
} catch {
|
||||
// 存储损坏,回退到后端数据
|
||||
} catch (err) {
|
||||
console.warn("[portal-shell] restore homework answers failed:", err);
|
||||
}
|
||||
}
|
||||
const initial: Record<string, string> = {};
|
||||
@@ -80,7 +142,6 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
||||
setAnswers(initial);
|
||||
}, [data, storageKey]);
|
||||
|
||||
// 答案变化时自动保存到 localStorage
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
localStorage.setItem(storageKey, JSON.stringify(answers));
|
||||
@@ -149,6 +210,8 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
||||
answers={answers}
|
||||
activeQuestion={activeQuestion}
|
||||
onNavigate={scrollToQuestion}
|
||||
maxAttempts={data.maxAttempts}
|
||||
attemptsUsed={data.attemptsUsed}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
@@ -190,22 +253,32 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左栏:题目导航(题号 + 答题状态)。
|
||||
*/
|
||||
function QuestionNavPanel({
|
||||
questions,
|
||||
answers,
|
||||
activeQuestion,
|
||||
onNavigate,
|
||||
maxAttempts,
|
||||
attemptsUsed,
|
||||
}: {
|
||||
questions: StudentHomeworkSubmitQuestion[];
|
||||
answers: Record<string, string>;
|
||||
activeQuestion: number;
|
||||
onNavigate: (questionNo: number) => void;
|
||||
maxAttempts?: number;
|
||||
attemptsUsed?: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.homework.submit");
|
||||
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">
|
||||
{questions.map((q) => {
|
||||
const answered =
|
||||
@@ -237,9 +310,6 @@ function QuestionNavPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中栏:题目列表 + 答案输入区。
|
||||
*/
|
||||
function QuestionListPanel({
|
||||
data,
|
||||
answers,
|
||||
@@ -271,9 +341,6 @@ function QuestionListPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单题卡片(题干 + 答案输入)。
|
||||
*/
|
||||
function QuestionCard({
|
||||
question,
|
||||
answer,
|
||||
@@ -287,9 +354,9 @@ function QuestionCard({
|
||||
questionRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
|
||||
onFocus: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.homework.submit");
|
||||
const isChoice =
|
||||
question.type === "single_choice" && question.options.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
@@ -304,7 +371,6 @@ function QuestionCard({
|
||||
</span>
|
||||
<p className="flex-1 text-sm">{question.question}</p>
|
||||
</div>
|
||||
|
||||
{isChoice ? (
|
||||
<div className="space-y-2 pl-8">
|
||||
{question.options.map((opt, idx) => (
|
||||
@@ -325,21 +391,20 @@ function QuestionCard({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={answer}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
placeholder="请输入答案..."
|
||||
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]"
|
||||
/>
|
||||
<div className="pl-8">
|
||||
<textarea
|
||||
value={answer}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
placeholder={t("answerPlaceholder")}
|
||||
rows={4}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏:自动保存提示 + 答题统计 + 提交按钮。
|
||||
*/
|
||||
function SubmitPanel({
|
||||
answeredCount,
|
||||
totalQuestions,
|
||||
@@ -355,15 +420,15 @@ function SubmitPanel({
|
||||
submitDisabled: boolean;
|
||||
onSubmitClick: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.homework.submit");
|
||||
return (
|
||||
<WorkbenchPanel title="提交">
|
||||
<WorkbenchPanel title={t("submitPanelTitle")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<p className="text-xs text-emerald-600">{autoSaveTip}</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{answeredCount}
|
||||
<span className="text-base text-muted-foreground">
|
||||
@@ -380,7 +445,6 @@ function SubmitPanel({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
@@ -394,9 +458,6 @@ function SubmitPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交确认对话框(二次确认)。
|
||||
*/
|
||||
function ConfirmDialog({
|
||||
message,
|
||||
cancelLabel,
|
||||
@@ -432,3 +493,236 @@ function ConfirmDialog({
|
||||
</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 || "--";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@
|
||||
* - studentLearningCenter ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* 复刻 CICD src/app/(dashboard)/student/learning/page.tsx:
|
||||
* - 3 张导航卡片(课程 / 作业 / 教材)
|
||||
* - 每张卡片含图标 + 标题 + 描述 + 统计 + 箭头
|
||||
* - 学生身份校验:无身份(data 为 null 且非 loading/error)显示 EmptyState
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
AlertCircle,
|
||||
PencilLine,
|
||||
Library,
|
||||
ChevronRight,
|
||||
UserX,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -39,7 +43,7 @@ interface LearningCardConfig {
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
href: string;
|
||||
stat: number | string;
|
||||
stat: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +70,9 @@ export function StudentLearningCenterClient(): React.ReactElement {
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// 学生身份校验:data 为 null 且非 loading/error 时视为无学生身份
|
||||
const noStudent = !loading && !error && !data;
|
||||
|
||||
const cards: LearningCardConfig[] = [
|
||||
{
|
||||
key: "courses",
|
||||
@@ -73,7 +80,7 @@ export function StudentLearningCenterClient(): React.ReactElement {
|
||||
titleKey: "cardCourses",
|
||||
descKey: "cardCoursesDesc",
|
||||
href: "/shell/student/courses",
|
||||
stat: data?.coursesCount ?? 0,
|
||||
stat: t("statCourses", { count: data?.coursesCount ?? 0 }),
|
||||
},
|
||||
{
|
||||
key: "homework",
|
||||
@@ -81,41 +88,28 @@ export function StudentLearningCenterClient(): React.ReactElement {
|
||||
titleKey: "cardHomework",
|
||||
descKey: "cardHomeworkDesc",
|
||||
href: "/shell/student/homework",
|
||||
stat:
|
||||
data != null
|
||||
? `${data.homeworkPending}/${data.homeworkDueSoon}`
|
||||
: "0/0",
|
||||
stat: t("statHomework", {
|
||||
pending: data?.homeworkPending ?? 0,
|
||||
dueSoon: data?.homeworkDueSoon ?? 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "textbooks",
|
||||
icon: BookOpen,
|
||||
icon: Library,
|
||||
titleKey: "cardTextbooks",
|
||||
descKey: "cardTextbooksDesc",
|
||||
href: "/shell/student/textbooks",
|
||||
stat: 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: "—",
|
||||
stat: t("statTextbooks", { count: data?.textbooksCount ?? 0 }),
|
||||
},
|
||||
];
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState icon={BookOpen} title={t("emptyTitle")} />
|
||||
) : undefined;
|
||||
const emptyNode = noStudent ? (
|
||||
<EmptyState
|
||||
icon={UserX}
|
||||
title={t("noStudentTitle")}
|
||||
description={t("noStudentDesc")}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
@@ -163,7 +157,7 @@ function LearningCard({
|
||||
<h3 className="text-base font-semibold">{t(config.titleKey)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t(config.descKey)}</p>
|
||||
</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}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -4,35 +4,37 @@
|
||||
* 学生在线请假页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - studentLeave:❌ schema 无此字段 → MSW 兜底
|
||||
* - studentLeave:❌ schema 无此字段 → MSW 兜底(支持分页 page/pageSize)
|
||||
* - submitLeaveRequest mutation:❌ schema 无此字段 → MSW 兜底
|
||||
* - studentClasses(用于班级下拉默认值):❌ schema 无此字段 → MSW 兜底
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-leave
|
||||
*
|
||||
* 组成:
|
||||
* - LeaveRequestForm:表单 + 校验 + 提交(独立组件)
|
||||
* - LeaveRequestList:列表 + 分页 + 空态(独立组件)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(记录空态)
|
||||
*
|
||||
* 关联: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 { useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
useStudentClasses,
|
||||
useStudentLeave,
|
||||
useSubmitLeaveRequest,
|
||||
type LeaveRequestInput,
|
||||
type StudentLeaveItem,
|
||||
} from "@/lib/api";
|
||||
import { useStudentClasses, useStudentLeave } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
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> 中。
|
||||
@@ -41,81 +43,42 @@ export function StudentLeaveClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.leave");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data: leaveItems, loading, error, refetch } = useStudentLeave();
|
||||
// @contract-pending:MSW 兜底(班级下拉默认值来源)
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
// @contract-pending:MSW 兜底(支持分页)
|
||||
const {
|
||||
data: leaveResult,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useStudentLeave({ page, pageSize });
|
||||
// @contract-pending:MSW 兜底(用于自动写入 classId,由 LeaveRequestForm 内部消费)
|
||||
const { data: classes } = useStudentClasses();
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: submitLeave, loading: submitting } = useSubmitLeaveRequest();
|
||||
|
||||
const [classId, setClassId] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// 自动选择第一个活跃班级作为 defaultClassId(透传给表单)
|
||||
const defaultClassId = (classes ?? []).find((c) => c.isActive)?.id ?? "";
|
||||
|
||||
// 默认选择第一个活跃班级
|
||||
useEffect(() => {
|
||||
if (!classId && classes) {
|
||||
const firstActive = classes.find((c) => c.isActive);
|
||||
if (firstActive) setClassId(firstActive.id);
|
||||
}
|
||||
}, [classes, classId]);
|
||||
const items = leaveResult?.items ?? [];
|
||||
const total = leaveResult?.total ?? 0;
|
||||
const currentPage = leaveResult?.page ?? page;
|
||||
const currentPageSize = leaveResult?.pageSize ?? pageSize;
|
||||
|
||||
const hasActiveClass = (classes ?? []).some((c) => c.isActive);
|
||||
|
||||
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 handlePageChange = useCallback((next: number): void => {
|
||||
setPage(Math.max(1, next));
|
||||
}, []);
|
||||
|
||||
const handleFormSubmitted = useCallback((): void => {
|
||||
// 提交成功后回到第 1 页并刷新
|
||||
setPage(1);
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
await submitLeave(input);
|
||||
notify.success(t("submitSuccess"));
|
||||
// 重置表单(保留班级选择),刷新历史
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setType("");
|
||||
setReason("");
|
||||
await refetch();
|
||||
} catch {
|
||||
notify.error(t("submitError"));
|
||||
} catch (err) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(err) }));
|
||||
}
|
||||
})();
|
||||
};
|
||||
}, [refetch, tCommon]);
|
||||
|
||||
const errorNode = error ? (
|
||||
<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 />}
|
||||
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">
|
||||
<DetailSection title={t("sectionForm")}>
|
||||
{!hasActiveClass ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noActiveClass")}
|
||||
</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>
|
||||
<LeaveRequestForm
|
||||
defaultClassId={defaultClassId}
|
||||
onSubmitted={handleFormSubmitted}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("sectionHistory")}>
|
||||
<LeaveHistoryTable items={leaveItems ?? []} />
|
||||
<LeaveRequestList
|
||||
items={items}
|
||||
total={total}
|
||||
page={currentPage}
|
||||
pageSize={currentPageSize}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</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");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - subjectOptions ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
@@ -15,9 +16,19 @@
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
ListPageShell,
|
||||
@@ -34,6 +45,10 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentLessonPlans();
|
||||
const { data: subjectOptions } = useSubjectOptions();
|
||||
|
||||
// 学科筛选状态(subject id)
|
||||
const [subjectFilter, setSubjectFilter] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -41,7 +56,16 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
||||
}
|
||||
}, [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 ? (
|
||||
<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")}
|
||||
description={t("description")}
|
||||
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}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={items.length === 0 && !loading}
|
||||
empty={visibleItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<StudentLessonPlansTable items={items} />
|
||||
<StudentLessonPlansGrid items={visibleItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
* 学生教案卡片网格(md:grid-cols-2 lg:grid-cols-3,复刻 CICD lesson-plan-list)。
|
||||
*/
|
||||
function StudentLessonPlansTable({
|
||||
function StudentLessonPlansGrid({
|
||||
items,
|
||||
}: {
|
||||
items: StudentLessonPlan[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.list");
|
||||
|
||||
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("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) => (
|
||||
<tr key={plan.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.subject}</td>
|
||||
<td className="p-3 text-muted-foreground">{plan.grade}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{plan.updatedAt}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="group flex flex-col transition-all hover:-translate-y-1 hover:shadow-md"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="line-clamp-2 text-base font-semibold">
|
||||
{plan.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<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}
|
||||
</p>
|
||||
<div className="mt-auto pt-2">
|
||||
<Link
|
||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||
className="inline-flex items-center text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
|
||||
// @contract-pending scope-check:范围校验待 core-edu 服务接入后启用
|
||||
/**
|
||||
* 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
@@ -13,12 +14,16 @@
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 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
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
useStudentLessonPlanView,
|
||||
@@ -35,6 +40,8 @@ import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*
|
||||
* 全高度布局(h-[calc(100vh-4rem)])+ 内部 overflow-y-auto,提升阅读体验。
|
||||
*/
|
||||
export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
@@ -72,22 +79,49 @@ export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/student/lesson-plans"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
||||
</DetailPageShell>
|
||||
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
description={data ? buildSubtitle(data, t) : undefined}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/student/lesson-plans"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
||||
</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({
|
||||
plan,
|
||||
@@ -95,12 +129,327 @@ function LessonPlanViewBody({
|
||||
plan: LessonPlanViewData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
return (
|
||||
<DetailSection title={t("sectionContent")}>
|
||||
<DetailField label={t("title")} value={plan.title} />
|
||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
||||
{plan.content}
|
||||
|
||||
// TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围
|
||||
// @contract-pending scope-check
|
||||
|
||||
// 已发布状态检查:未发布时阻止渲染内容
|
||||
if (plan.status && plan.status !== "published") {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/50 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("notPublished")}</p>
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,181 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentPractice schema 未实现,全 MSW 兜底
|
||||
/**
|
||||
* 学生自适应练习列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentPractice ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-practice
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:ListPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为空时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
|
||||
*/
|
||||
import { Dumbbell } from "lucide-react";
|
||||
// @contract-pending: studentPractice / studentKnowledgePoints / startPracticeSession schema -> MSW
|
||||
import {
|
||||
Award,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Dumbbell,
|
||||
Filter,
|
||||
Target,
|
||||
TrendingUp,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
* 练习列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const;
|
||||
|
||||
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 {
|
||||
const t = useTranslations("studentDomain.practice.list");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -46,7 +185,13 @@ export function StudentPracticeListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : 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 (
|
||||
<ListPageShell
|
||||
@@ -55,119 +200,334 @@ export function StudentPracticeListClient(): React.ReactElement {
|
||||
icon={<Dumbbell className="size-6" />}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={!loading && !error && !data}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data ? <PracticeBody data={data} /> : null}
|
||||
{data ? (
|
||||
<div className="space-y-6">
|
||||
<StatsGrid columns={4}>
|
||||
<StatCard
|
||||
title={t("fieldTotalSessions")}
|
||||
value={String(stats?.totalSessions ?? 0)}
|
||||
icon={Target}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldCompletedSessions")}
|
||||
value={String(stats?.completedSessions ?? 0)}
|
||||
icon={CheckCircle2}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldTotalQuestions")}
|
||||
value={String(stats?.totalQuestions ?? 0)}
|
||||
icon={Award}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldCorrectRate")}
|
||||
value={formatRate(stats?.correctRate ?? 0)}
|
||||
icon={TrendingUp}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</StatsGrid>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<PracticeStarter
|
||||
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>
|
||||
<PracticeHistory sessions={history} />
|
||||
</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 PracticeBody({
|
||||
data,
|
||||
function PracticeStarter({
|
||||
filteredKps,
|
||||
groupedKps,
|
||||
selectedKpIds,
|
||||
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,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useStudentPractice>["data"]>;
|
||||
kp: StudentKnowledgePoint;
|
||||
selected: boolean;
|
||||
onToggle: (kpId: string) => void;
|
||||
}): 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
|
||||
title={t("fieldTotalSessions")}
|
||||
value={String(data.stats.totalSessions)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldCompletedSessions")}
|
||||
value={String(data.stats.completedSessions)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldTotalQuestions")}
|
||||
value={String(data.stats.totalQuestions)}
|
||||
/>
|
||||
<StatCard
|
||||
title={t("fieldCorrectRate")}
|
||||
value={formatRate(data.stats.correctRate)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("sectionHistory")}</h2>
|
||||
<PracticeHistoryTable history={data.history} />
|
||||
</section>
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史会话表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function PracticeHistoryTable({
|
||||
history,
|
||||
function PracticeHistory({
|
||||
sessions,
|
||||
}: {
|
||||
history: NonNullable<
|
||||
sessions: NonNullable<
|
||||
ReturnType<typeof useStudentPractice>["data"]
|
||||
>["history"];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.practice.list");
|
||||
if (history.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t("emptyTitle")}</p>;
|
||||
if (sessions.length === 0) {
|
||||
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 (
|
||||
<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("colSessionDate")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("colTotalQuestions")}
|
||||
</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
|
||||
href={`/shell/student/practice/${item.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => {
|
||||
const correctCount = Math.round(
|
||||
session.totalQuestions * session.correctRate,
|
||||
);
|
||||
const wrongCount = session.totalQuestions - correctCount;
|
||||
return (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={`/shell/student/practice/${session.id}`}
|
||||
className="block"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 格式化 0-1 的比率为百分比字符串。 */
|
||||
function formatRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** 格式化 ISO 日期字符串为本地化展示。 */
|
||||
function formatDate(isoDate: string): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
* - studentClasses(用于班级筛选器选项):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-schedule
|
||||
*
|
||||
* URL 状态:?classId=xxx
|
||||
* URL 状态:?classId=xxx(默认 all)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
* 复刻 CICD:student-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 { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -24,23 +26,38 @@ import {
|
||||
useStudentSchedule,
|
||||
type StudentScheduleItem,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/** 周一到周日(weekday 1-7 → 周一到周日)。 */
|
||||
const WEEKDAY_LABELS = [
|
||||
"周一",
|
||||
"周二",
|
||||
"周三",
|
||||
"周四",
|
||||
"周五",
|
||||
"周六",
|
||||
"周日",
|
||||
/** 周一到周日(weekday 1-7 → monday 到 sunday),i18n 键名映射。 */
|
||||
const WEEKDAY_KEYS = [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
] 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> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
@@ -57,29 +74,20 @@ export function StudentScheduleListClient(): React.ReactElement {
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data: scheduleItems, loading, error } = useStudentSchedule();
|
||||
// 班级筛选器选项:studentClasses 提供班级列表
|
||||
// 注意:StudentScheduleItem 当前无 classId 字段,筛选仅做 URL 状态记录,
|
||||
// 实际过滤待后端契约补齐(@contract-pending)
|
||||
const { data: classes } = useStudentClasses();
|
||||
|
||||
const groupedByWeekday = useMemo(() => {
|
||||
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 items = 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 params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
if (value && value !== "all") {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
@@ -98,7 +106,22 @@ export function StudentScheduleListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : 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 (
|
||||
<ListPageShell
|
||||
@@ -107,12 +130,12 @@ export function StudentScheduleListClient(): React.ReactElement {
|
||||
icon={<Calendar className="size-6" />}
|
||||
filters={
|
||||
<select
|
||||
value={classId}
|
||||
value={classId || "all"}
|
||||
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")}
|
||||
>
|
||||
<option value="">{t("allClasses")}</option>
|
||||
<option value="all">{t("allClasses")}</option>
|
||||
{(classes ?? []).map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name}
|
||||
@@ -125,71 +148,114 @@ export function StudentScheduleListClient(): React.ReactElement {
|
||||
empty={totalItems === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
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">
|
||||
{groupedByWeekday.map((group) => (
|
||||
<WeekdayScheduleGroup
|
||||
key={group.weekday}
|
||||
weekday={group.weekday}
|
||||
items={group.items}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StudentScheduleView items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按星期分组的课表展示。
|
||||
* 课表周视图(复刻 CICD StudentScheduleView)。
|
||||
*
|
||||
* 每个星期一张卡片(lg:grid-cols-2),今日卡片高亮,
|
||||
* 卡片内按 startTime 排序展示节次。
|
||||
*/
|
||||
function WeekdayScheduleGroup({
|
||||
weekday,
|
||||
function StudentScheduleView({
|
||||
items,
|
||||
}: {
|
||||
weekday: number;
|
||||
items: StudentScheduleItem[];
|
||||
}): React.ReactElement {
|
||||
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 (
|
||||
<section className="rounded-xl border bg-card">
|
||||
<header className="border-b p-4">
|
||||
<h3 className="text-base font-semibold">{label}</h3>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<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("colTeacher")}</th>
|
||||
<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>
|
||||
<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.classroom}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{item.time}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{WEEKDAY_KEYS.map((dayKey, idx) => {
|
||||
const weekday = idx + 1;
|
||||
const dayItems = itemsByDay.get(weekday) ?? [];
|
||||
const isToday = weekday === todayKey;
|
||||
const label = t(`weekdays.${dayKey}`);
|
||||
return (
|
||||
<Card
|
||||
key={weekday}
|
||||
className={cn(
|
||||
isToday && "border-primary ring-1 ring-primary/30 shadow-sm",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{label}</span>
|
||||
{isToday ? (
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-primary-foreground">
|
||||
{t("today")}
|
||||
</span>
|
||||
) : 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}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单条课表展示(节次 + 科目 + 教师 + 教室 + 时间 + 课程跳转)。 */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentTextbookChapters schema 未实现,全 MSW 兜底
|
||||
// @contract-pending student-grade-filter:理想做法是后端 BFF 在 studentTextbookChapters
|
||||
// resolver 中按学生年级校验;当前在前端用 useStudentClasses().grade 兜底校验。
|
||||
/**
|
||||
* 学生教材章节阅读器 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentTextbookChapters(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 学生年级:来自 studentClasses(首个活跃班级的 grade),前端二次校验
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
@@ -15,14 +18,22 @@
|
||||
*
|
||||
* 关联: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 { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
useStudentClasses,
|
||||
useStudentTextbookChapters,
|
||||
type StudentTextbookChapter,
|
||||
type StudentTextbookChapterContent,
|
||||
type StudentTextbookChapterSection,
|
||||
} from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
@@ -42,16 +53,38 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
||||
const params = useParams<{ id: string }>();
|
||||
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-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentTextbookChapters(textbookId);
|
||||
const {
|
||||
data,
|
||||
loading: loadingChapters,
|
||||
error,
|
||||
} = useStudentTextbookChapters(textbookId);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
|
||||
const loading = loadingClasses || loadingChapters;
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||
}
|
||||
}, [error, tCommon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (classesError) {
|
||||
notify.error(
|
||||
tCommon("error.loadFailed", { message: String(classesError) }),
|
||||
);
|
||||
}
|
||||
}, [classesError, tCommon]);
|
||||
|
||||
// 选中首个章节
|
||||
useEffect(() => {
|
||||
if (data && data.chapters.length > 0 && !selectedId) {
|
||||
@@ -62,6 +95,12 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
||||
}
|
||||
}, [data, selectedId]);
|
||||
|
||||
// 年级不匹配警告(前端兜底校验,理想:BFF resolver 校验)
|
||||
const gradeMismatch =
|
||||
!loadingClasses && !classesError && studentGrade && data
|
||||
? data.grade !== studentGrade
|
||||
: false;
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -95,6 +134,9 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
||||
{data ? (
|
||||
<ChaptersReader
|
||||
chapters={data.chapters}
|
||||
subject={data.subject}
|
||||
grade={data.grade}
|
||||
gradeMismatch={gradeMismatch}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
@@ -104,19 +146,58 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节阅读器(左侧章节列表 + 右侧阅读区)。
|
||||
* 章节阅读器(顶栏 + 左侧章节列表 + 右侧阅读区)。
|
||||
*/
|
||||
function ChaptersReader({
|
||||
chapters,
|
||||
subject,
|
||||
grade,
|
||||
gradeMismatch,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
chapters: StudentTextbookChapter[];
|
||||
subject: string;
|
||||
grade: string;
|
||||
gradeMismatch: boolean;
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}): React.ReactElement {
|
||||
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) {
|
||||
return (
|
||||
@@ -132,11 +213,14 @@ function ChaptersReader({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
|
||||
<DetailSection title={t("sectionChapters")}>
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
) : (
|
||||
<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]">
|
||||
<DetailSection title={t("sectionChapters")}>
|
||||
<ul className="space-y-1">
|
||||
{chapters.map((ch, idx) => (
|
||||
<li key={ch.id}>
|
||||
@@ -144,29 +228,160 @@ function ChaptersReader({
|
||||
type="button"
|
||||
onClick={() => onSelect(ch.id)}
|
||||
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
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-xs opacity-70">
|
||||
{idx + 1}.
|
||||
{t("chapterLabel", { n: idx + 1 })}
|
||||
</span>
|
||||
<span className="truncate">{ch.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DetailSection>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("sectionContent")}>
|
||||
<h3 className="text-lg font-semibold">{selected.title}</h3>
|
||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
||||
{selected.content}
|
||||
</div>
|
||||
</DetailSection>
|
||||
<DetailSection
|
||||
title={t("sectionContent")}
|
||||
actions={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<h3 className="text-lg font-semibold">{selected.title}</h3>
|
||||
<ChapterContent chapter={selected} />
|
||||
</DetailSection>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentTextbooks schema 未实现,全 MSW 兜底
|
||||
// @contract-pending student-grade-filter:理想做法是后端 BFF 在 studentTextbooks
|
||||
// resolver 中按学生年级过滤;当前在前端用 useStudentClasses().grade 兜底过滤。
|
||||
/**
|
||||
* 学生教材列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - studentTextbooks(q, subject, grade) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 学生年级:来自 studentClasses(首个活跃班级的 grade),前端二次过滤
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
*
|
||||
* URL 状态:?q=&subject=&grade=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
* - 区分有/无筛选的空态
|
||||
* - 学生年级未设置时显示提示
|
||||
*
|
||||
* 关联: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 { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
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 { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
@@ -28,15 +38,22 @@ import {
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
const SUBJECT_OPTIONS = ["语文", "数学", "英语", "物理", "化学", "生物"];
|
||||
const GRADE_OPTIONS = [
|
||||
"一年级",
|
||||
"二年级",
|
||||
"三年级",
|
||||
"七年级",
|
||||
"八年级",
|
||||
"九年级",
|
||||
];
|
||||
const SUBJECT_OPTION_KEYS = [
|
||||
"chinese",
|
||||
"math",
|
||||
"english",
|
||||
"physics",
|
||||
"chemistry",
|
||||
"biology",
|
||||
] as const;
|
||||
const GRADE_OPTION_KEYS = [
|
||||
"grade1",
|
||||
"grade2",
|
||||
"grade3",
|
||||
"grade7",
|
||||
"grade8",
|
||||
"grade9",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -53,14 +70,36 @@ export function StudentTextbooksListClient(): React.ReactElement {
|
||||
const subject = searchParams.get("subject") ?? "";
|
||||
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-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudentTextbooks({
|
||||
const {
|
||||
data,
|
||||
loading: loadingTextbooks,
|
||||
error,
|
||||
} = useStudentTextbooks({
|
||||
q: q || undefined,
|
||||
subject: subject || 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 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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -83,7 +128,34 @@ export function StudentTextbooksListClient(): React.ReactElement {
|
||||
</div>
|
||||
) : 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
|
||||
icon={BookOpen}
|
||||
title={t("emptyTitle")}
|
||||
@@ -94,46 +166,61 @@ export function StudentTextbooksListClient(): React.ReactElement {
|
||||
/>
|
||||
);
|
||||
|
||||
const filtersNode = (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => updateQuery("subject", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("subjectFilter")}
|
||||
>
|
||||
<option value="">{t("allSubjects")}</option>
|
||||
{SUBJECT_OPTION_KEYS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`subjects.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={grade}
|
||||
onChange={(e) => updateQuery("grade", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("gradeFilter")}
|
||||
>
|
||||
<option value="">{t("allGrades")}</option>
|
||||
{GRADE_OPTION_KEYS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{t(`grades.${g}`)}
|
||||
</option>
|
||||
))}
|
||||
</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={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => updateQuery("subject", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("subjectFilter")}
|
||||
>
|
||||
<option value="">{t("allSubjects")}</option>
|
||||
{SUBJECT_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={grade}
|
||||
onChange={(e) => updateQuery("grade", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("gradeFilter")}
|
||||
>
|
||||
<option value="">{t("allGrades")}</option>
|
||||
{GRADE_OPTIONS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
filters={filtersNode}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={items.length === 0 && !loading}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - aiAssistSessions(teacherId) ❌ → MSW 兜底
|
||||
* - sendAiMessage(input) mutation ❌ → MSW 兜底
|
||||
* - 聊天流式响应 → /api/ai/chat/stream(AiChatPanel 内部 useAiChatStream,SSE)
|
||||
*
|
||||
* 布局(WorkbenchPageShell):
|
||||
* - left:会话列表
|
||||
* - center:聊天区域(消息历史 + 输入框)
|
||||
* - left:会话列表(MSW 兜底)
|
||||
* - center:AI 聊天面板(AiChatPanel widget 变体,SSE 流式逐 token 渲染)
|
||||
* - right:上下文信息(统计)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
@@ -19,12 +19,11 @@
|
||||
*
|
||||
* 关联: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 { useMemo, useState } from "react";
|
||||
|
||||
import { useAiAssistSessions, useSendAiMessage } from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { useAiAssistSessions } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
@@ -35,13 +34,7 @@ import {
|
||||
formatAiDate,
|
||||
totalMessageCount,
|
||||
} from "@/features/teacher/ai/transformations";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
import { AiChatPanel } from "@/features/teacher/ai/components/ai-chat-panel";
|
||||
|
||||
export function AiAssistClient(): React.ReactElement {
|
||||
const t = useTranslations("ai");
|
||||
@@ -49,13 +42,10 @@ export function AiAssistClient(): React.ReactElement {
|
||||
|
||||
const teacherId = "dev-teacher-001";
|
||||
const { data: sessions, loading, error } = useAiAssistSessions(teacherId);
|
||||
const sendMessage = useSendAiMessage();
|
||||
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const sessionList = useMemo(() => {
|
||||
const items = sessions ?? [];
|
||||
@@ -67,34 +57,6 @@ export function AiAssistClient(): React.ReactElement {
|
||||
|
||||
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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -152,59 +114,15 @@ export function AiAssistClient(): React.ReactElement {
|
||||
}
|
||||
center={
|
||||
<WorkbenchPanel title={t("assist.chatTitle")}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-2">
|
||||
{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")}
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
aria-label={t("assist.inputPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || sendMessage.loading}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{t("assist.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AiChatPanel
|
||||
variant="widget"
|
||||
placeholder={t("assist.inputPlaceholder")}
|
||||
suggestedPrompts={[
|
||||
t("chat.suggestedPrompts.teacher.0"),
|
||||
t("chat.suggestedPrompts.teacher.1"),
|
||||
t("chat.suggestedPrompts.teacher.2"),
|
||||
]}
|
||||
/>
|
||||
</WorkbenchPanel>
|
||||
}
|
||||
right={
|
||||
|
||||
@@ -27,12 +27,7 @@ import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
attendanceStatusToBadgeClass,
|
||||
formatAttendanceDate,
|
||||
formatAttendanceDay,
|
||||
formatAttendanceStatus,
|
||||
} from "@/features/teacher/attendance/transformations";
|
||||
import { AttendanceRecordList } from "./components";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -157,85 +152,7 @@ export function AttendanceListClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AttendanceTable items={filteredItems} />
|
||||
<AttendanceRecordList records={filteredItems} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
progressToColorClass,
|
||||
} 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> 中。
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
formatCoursePlanStatus,
|
||||
} from "@/features/teacher/course-plans/transformations";
|
||||
|
||||
// P2 迁移:模板选择器(供列表页"从模板创建"入口配套使用)
|
||||
export { TemplatePickerDialog } from "./components/template-picker-dialog";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
|
||||
@@ -37,6 +37,52 @@ import {
|
||||
formatStudentCount,
|
||||
masteryToColorClass,
|
||||
} 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 消费。
|
||||
* 弱项知识点映射为 knowledgePointStats(mastery 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> 中。
|
||||
@@ -95,6 +141,13 @@ export function DiagnosticClassDetailClient(): React.ReactElement {
|
||||
{data ? (
|
||||
<RecommendationsSection recommendations={data.recommendations} />
|
||||
) : null}
|
||||
{data ? (
|
||||
<DiagnosticServiceProvider>
|
||||
<ClassDiagnosticView
|
||||
summary={buildClassMasterySummary(data, classId)}
|
||||
/>
|
||||
</DiagnosticServiceProvider>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,104 @@ import {
|
||||
masteryLevelToBadgeClass,
|
||||
} from "@/features/teacher/diagnostic/student-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 使用 UPPERCASE(PUBLISHED/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> 中。
|
||||
@@ -101,6 +199,13 @@ export function StudentDiagnosticClient(): React.ReactElement {
|
||||
<KnowledgePointsSection knowledgePoints={data.knowledge_points} />
|
||||
) : null}
|
||||
{data ? <StudentReportsSection reports={data.reports} /> : null}
|
||||
{data ? (
|
||||
<StudentDiagnosticView
|
||||
summary={buildStudentMasterySummary(data)}
|
||||
reports={buildStudentReports(data.reports, data.student_name)}
|
||||
role="teacher"
|
||||
/>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCreateElective, type CreateElectiveInput } from "@/lib/api";
|
||||
import { ElectivePageLayout } from "./components";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
@@ -113,101 +114,105 @@ function ElectiveFormInner({
|
||||
};
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("create.title")}
|
||||
description={t("create.description")}
|
||||
icon={<BookMarked className="size-6" />}
|
||||
backHref="/shell/teacher/elective"
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("create.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
||||
}
|
||||
>
|
||||
<FormField label={t("create.nameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.namePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<ElectivePageLayout header={null} className="p-0 space-y-0">
|
||||
<FormPageShell
|
||||
title={t("create.title")}
|
||||
description={t("create.description")}
|
||||
icon={<BookMarked className="size-6" />}
|
||||
backHref="/shell/teacher/elective"
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("create.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<FormField label={t("create.nameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.namePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("create.descriptionPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("create.descriptionPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.subjectLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.subjectPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.subjectLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.subjectPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.gradeLevelLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeLevel}
|
||||
onChange={(e) => setGradeLevel(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.gradeLevelPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.gradeLevelLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeLevel}
|
||||
onChange={(e) => setGradeLevel(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.gradeLevelPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.semesterLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.semesterPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.semesterLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.semesterPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.capacityLabel")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("create.capacityHint")}
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.teacherIdLabel")}>
|
||||
<input
|
||||
type="text"
|
||||
value={teacherId}
|
||||
onChange={(e) => setTeacherId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.teacherIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.capacityLabel")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("create.capacityHint")}
|
||||
{t("create.contractPending")}
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.teacherIdLabel")}>
|
||||
<input
|
||||
type="text"
|
||||
value={teacherId}
|
||||
onChange={(e) => setTeacherId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("create.teacherIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("create.contractPending")}
|
||||
</p>
|
||||
</FormPageShell>
|
||||
</FormPageShell>
|
||||
</ElectivePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useUpdateElective,
|
||||
type UpdateElectiveInput,
|
||||
} from "@/lib/api";
|
||||
import { ElectivePageLayout } from "./components";
|
||||
import {
|
||||
FormPageShell,
|
||||
FormPageSkeleton,
|
||||
@@ -141,112 +142,114 @@ export function ElectiveEditClient(): React.ReactElement {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("edit.title")}
|
||||
description={t("edit.description")}
|
||||
icon={<BookMarked className="size-6" />}
|
||||
backHref="/shell/teacher/elective"
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("edit.save")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
formError ? (
|
||||
<p className="text-sm text-destructive">{formError}</p>
|
||||
) : undefined
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={errorNode ?? emptyNode ?? <FormPageSkeleton />}
|
||||
>
|
||||
{data && initialized ? (
|
||||
<>
|
||||
<FormField label={t("create.nameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<ElectivePageLayout header={null} className="p-0 space-y-0">
|
||||
<FormPageShell
|
||||
title={t("edit.title")}
|
||||
description={t("edit.description")}
|
||||
icon={<BookMarked className="size-6" />}
|
||||
backHref="/shell/teacher/elective"
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("edit.save")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
formError ? (
|
||||
<p className="text-sm text-destructive">{formError}</p>
|
||||
) : undefined
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={errorNode ?? emptyNode ?? <FormPageSkeleton />}
|
||||
>
|
||||
{data && initialized ? (
|
||||
<>
|
||||
<FormField label={t("create.nameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.subjectLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.subjectLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.gradeLevelLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeLevel}
|
||||
onChange={(e) => setGradeLevel(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.gradeLevelLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeLevel}
|
||||
onChange={(e) => setGradeLevel(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.semesterLabel")}>
|
||||
<input
|
||||
type="text"
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.semesterLabel")}>
|
||||
<input
|
||||
type="text"
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.capacityLabel")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.capacityLabel")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("create.teacherIdLabel")}>
|
||||
<input
|
||||
type="text"
|
||||
value={teacherId}
|
||||
onChange={(e) => setTeacherId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("create.teacherIdLabel")}>
|
||||
<input
|
||||
type="text"
|
||||
value={teacherId}
|
||||
onChange={(e) => setTeacherId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("edit.statusLabel")}>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="OPEN">{t("list.statusOpen")}</option>
|
||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label={t("edit.statusLabel")}>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="OPEN">{t("list.statusOpen")}</option>
|
||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.contractPending")}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</FormPageShell>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.contractPending")}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</FormPageShell>
|
||||
</ElectivePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
formatElectiveStatus,
|
||||
formatEnrollmentCount,
|
||||
} from "@/features/teacher/elective/transformations";
|
||||
import { ElectivePageLayout } from "./components";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -130,7 +131,9 @@ export function ElectiveListClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ElectiveTable items={filteredItems} />
|
||||
<ElectivePageLayout header={null} className="space-y-4 p-0">
|
||||
<ElectiveTable items={filteredItems} />
|
||||
</ElectivePageLayout>
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,68 @@
|
||||
*/
|
||||
|
||||
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 / subjectName(portal-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> = {
|
||||
|
||||
@@ -26,12 +26,7 @@ import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatExamDate,
|
||||
formatExamStatus,
|
||||
formatDuration,
|
||||
parseTotalScore,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
import { ExamDataTable } from "./components/exam-data-table";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -130,97 +125,7 @@ export function ExamsListClient(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ExamsTable items={filteredItems} />
|
||||
<ExamDataTable data={filteredItems} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ import {
|
||||
formatExamDate,
|
||||
} 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> 中。
|
||||
*/
|
||||
|
||||
@@ -246,3 +246,86 @@ export function toGradeListItem(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isOverdue,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
import { ExcellentSubmissions } from "@/features/teacher/homework/components/excellent-submissions";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
@@ -178,6 +179,8 @@ function HomeworkDetailBody({
|
||||
<DetailSection title={t("detail.sectionInlineGrade")}>
|
||||
<InlineGradeForm homeworkId={homework.id} />
|
||||
</DetailSection>
|
||||
|
||||
<ExcellentSubmissions items={[]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
WorkbenchPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
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 {
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
@@ -114,7 +116,9 @@ export function ScanGradingClient(): React.ReactElement {
|
||||
|
||||
/**
|
||||
* 扫描图片预览面板(左栏)。
|
||||
* 注:当前无真实扫描图片,使用占位符 + 提示。
|
||||
* 注:当前无真实扫描图片 API,使用 ScanImageViewer 渲染空态。
|
||||
* 后端补齐 scanAttachments 查询后,传入真实 images。
|
||||
* @contract-pending:scanAttachments 查询契约待补齐。
|
||||
*/
|
||||
function ScanPreviewPanel({
|
||||
submissionId,
|
||||
@@ -122,19 +126,19 @@ function ScanPreviewPanel({
|
||||
submissionId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
// @contract-pending:后端补齐后改为 useSubmissionScans(submissionId) 拉取
|
||||
const scans: ScanImage[] = [];
|
||||
return (
|
||||
<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="text-center">
|
||||
<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 className="min-h-[300px] flex-1">
|
||||
<ScanImageViewer images={scans} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.imageContractPending")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.imagePlaceholder", { id: submissionId })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 practices(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 自适应练习会话查询/创建 ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#practice-list
|
||||
*
|
||||
* URL 状态:?classId=xxx &subjectId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 标签页结构:
|
||||
* - 练习与作业:教师布置的练习列表(原有功能)
|
||||
* - 自适应练习:发起自适应练习 + 查看本人练习历史(迁移自 CICD adaptive-practice)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState 含 next action)
|
||||
*
|
||||
* 关联: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 { 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 { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
FilterBar,
|
||||
FilterSearchInput,
|
||||
} from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} 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 {
|
||||
formatAvgScore,
|
||||
formatPracticeDate,
|
||||
@@ -33,6 +54,8 @@ import {
|
||||
isPracticeOverdue,
|
||||
practiceStatusToBadgeClass,
|
||||
} from "@/features/teacher/practice/transformations";
|
||||
import { PracticeHistory } from "./components/practice-history";
|
||||
import { PracticeStarter } from "./components/practice-starter";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -40,6 +63,36 @@ import {
|
||||
*/
|
||||
export function PracticeListClient(): React.ReactElement {
|
||||
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 router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -93,51 +146,48 @@ export function PracticeListClient(): React.ReactElement {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
placeholder={t("list.classPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.classPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||
placeholder={t("list.subjectPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.subjectPlaceholder")}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
|
||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={
|
||||
<>
|
||||
<FilterBar variant="between">
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
placeholder={t("list.classPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.classPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||
placeholder={t("list.subjectPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.subjectPlaceholder")}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
|
||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||
</select>
|
||||
</FilterBar>
|
||||
|
||||
{errorNode ? (
|
||||
errorNode
|
||||
) : loading ? (
|
||||
<ListPageSkeleton rows={5} />
|
||||
) : filteredItems.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
@@ -146,16 +196,109 @@ export function PracticeListClient(): React.ReactElement {
|
||||
onClick: () => updateQuery("q", ""),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PracticeTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
) : (
|
||||
<PracticeTable items={filteredItems} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</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();
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
questionTypeToBadgeClass,
|
||||
truncateContent,
|
||||
} 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> 中
|
||||
@@ -110,12 +112,22 @@ export function QuestionsListClient(): React.ReactElement {
|
||||
description={t("list.description")}
|
||||
icon={<HelpCircle className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/shell/teacher/questions">
|
||||
<Plus className="mr-1 size-4" />
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportExportButtons
|
||||
filter={{
|
||||
type: typeFilter || undefined,
|
||||
difficulty: difficultyFilter || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
q: q || undefined,
|
||||
}}
|
||||
/>
|
||||
<Button asChild>
|
||||
<Link href="/shell/teacher/questions">
|
||||
<Plus className="mr-1 size-4" />
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
@@ -248,12 +260,7 @@ function QuestionsTable({
|
||||
{formatQuestionDate(q.createdAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/questions?id=${q.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
<QuestionActions question={q} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 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
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
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 { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
import { ScheduleChangeForm } from "./components/schedule-change-form";
|
||||
import {
|
||||
formatScheduleChangeDate,
|
||||
formatScheduleChangeDay,
|
||||
formatScheduleChangeStatus,
|
||||
formatScheduleChangeType,
|
||||
getScheduleChangeSummary,
|
||||
scheduleChangeStatusToBadgeClass,
|
||||
scheduleChangeTypeToBadgeClass,
|
||||
} from "@/features/teacher/schedule-changes/transformations";
|
||||
ScheduleChangeList,
|
||||
type ScheduleChangeReviewSubmitInput,
|
||||
} from "./components/schedule-change-list";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
@@ -45,18 +58,42 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useScheduleChanges({
|
||||
const { data, loading, error, refetch } = useScheduleChanges({
|
||||
classId: classId || undefined,
|
||||
status: statusFilter || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: createScheduleChange, loading: creating } =
|
||||
useCreateScheduleChange();
|
||||
|
||||
// 班级下拉数据(复用 classes 域的 useClasses hook,通过 @/lib/api 统一出口)
|
||||
// @contract-pending:MSW 兜底
|
||||
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)—— 后端补齐列表查询后改服务端筛选
|
||||
const filteredItems = useMemo<ScheduleChangeListItem[]>(() => {
|
||||
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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -96,6 +170,12 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<CalendarClock className="size-6" />}
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("list.createAction")}
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
@@ -145,119 +225,29 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调课列表表格(纯展示组件,对齐 §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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
isTextbookEditable,
|
||||
sortChaptersByOrder,
|
||||
} from "@/features/teacher/textbooks/transformations";
|
||||
import { KnowledgeGraph } from "./components/knowledge-graph";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
@@ -111,10 +112,33 @@ export function TextbookDetailClient(): React.ReactElement {
|
||||
mswNotice={t("detail.chaptersMswNotice")}
|
||||
/>
|
||||
) : null}
|
||||
{data ? <KnowledgeGraphSection textbookId={data.id} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识图谱区(详情页内嵌,使用迁移后的 KnowledgeGraph 组件)。
|
||||
*
|
||||
* 容器使用 h-96(Tailwind 默认阶梯 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 详情页模板)。
|
||||
*/
|
||||
|
||||
@@ -2327,31 +2327,21 @@ export function useUpdateViewport(): {
|
||||
// ============================================================
|
||||
export function useAdminStudents(
|
||||
filter: StudentFilter,
|
||||
pagination: Pagination,
|
||||
): UseQueryResult<PaginatedResult<AdminStudent>> {
|
||||
const result = useWidgetQuery<
|
||||
{ adminStudents: PaginatedResult<AdminStudent> },
|
||||
{ filter: StudentFilter; limit: number; offset: number }
|
||||
>(GET_ADMIN_STUDENTS_DOC, {
|
||||
filter,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
});
|
||||
{ filter: StudentFilter }
|
||||
>(GET_ADMIN_STUDENTS_DOC, { filter });
|
||||
return { ...result, data: result.data?.adminStudents };
|
||||
}
|
||||
|
||||
export function useAdminTeachers(
|
||||
filter: TeacherFilter,
|
||||
pagination: Pagination,
|
||||
): UseQueryResult<PaginatedResult<AdminTeacher>> {
|
||||
const result = useWidgetQuery<
|
||||
{ adminTeachers: PaginatedResult<AdminTeacher> },
|
||||
{ filter: TeacherFilter; limit: number; offset: number }
|
||||
>(GET_ADMIN_TEACHERS_DOC, {
|
||||
filter,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
});
|
||||
{ filter: TeacherFilter }
|
||||
>(GET_ADMIN_TEACHERS_DOC, { filter });
|
||||
return { ...result, data: result.data?.adminTeachers };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,12 @@ import { ApiError } from "./errors";
|
||||
import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
|
||||
import {
|
||||
GET_USERS_DOC,
|
||||
GET_USER_DOC,
|
||||
UPDATE_USER_STATUS_DOC,
|
||||
UPDATE_USER_ROLE_DOC,
|
||||
UPDATE_USER_DOC,
|
||||
DELETE_USER_DOC,
|
||||
ASSIGN_USER_ROLES_DOC,
|
||||
GET_ROLES_DOC,
|
||||
GET_PERMISSIONS_DOC,
|
||||
UPDATE_ROLE_PERMISSIONS_DOC,
|
||||
@@ -28,6 +32,8 @@ import {
|
||||
GET_INVITATION_CODES_DOC,
|
||||
CREATE_INVITATION_CODE_DOC,
|
||||
REVOKE_INVITATION_CODE_DOC,
|
||||
GENERATE_INVITATION_CODES_DOC,
|
||||
DELETE_INVITATION_CODES_DOC,
|
||||
GET_SCHOOL_DOC,
|
||||
UPDATE_SCHOOL_DOC,
|
||||
GET_PLUGIN_REGISTRY_DOC,
|
||||
@@ -50,14 +56,48 @@ export interface User {
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
/**
|
||||
* 手机号(@contract-pending:schema 未就绪,MSW 兜底)。
|
||||
* 缺省视为未知,UI 用 "-" 占位。
|
||||
*/
|
||||
phone?: string;
|
||||
/**
|
||||
* 多角色列表(@contract-pending:schema 未就绪,MSW 兜底)。
|
||||
* 替换语义:与 `role` 共存,UI 优先渲染 `roles`,缺省回退 `[role]`。
|
||||
*/
|
||||
roles?: string[];
|
||||
/**
|
||||
* 更新时间 ISO 字符串(列表查询可选返回)。
|
||||
* 缺省视为未知,UI 用 `-` 占位。
|
||||
* @contract-pending schema 无此字段,MSW 兜底
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* 用户类型(internal/external,@contract-pending:schema 未就绪,MSW 兜底)。
|
||||
* 缺省视为未知,UI 不渲染类型徽章。
|
||||
*/
|
||||
userType?: string;
|
||||
}
|
||||
|
||||
export type UserQueryVars = {
|
||||
role: string | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @contract-pending 真实分页待 IAM 契约就绪,MSW 兜底时忽略 */
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户编辑输入(@contract-pending:schema 未就绪,MSW 兜底)。
|
||||
* 所有字段可选,仅传入需变更的字段。
|
||||
*/
|
||||
export interface UserUpdateInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types: RBAC
|
||||
// ============================================================
|
||||
@@ -67,6 +107,11 @@ export interface Permission {
|
||||
resource: string;
|
||||
action: string;
|
||||
description: string;
|
||||
/**
|
||||
* 权限值(如 `user:create`,@contract-pending:schema 未就绪,MSW 兜底)。
|
||||
* 缺省视为未知,UI 用 `-` 占位。
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface RolePermission {
|
||||
@@ -90,6 +135,29 @@ export interface Role {
|
||||
* 列表 MSW 兜底可能缺省,UI 需容忍 undefined。
|
||||
*/
|
||||
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-pending:schema 未就绪,MSW 兜底)。
|
||||
* 缺省视为未知,UI 用 `-` 占位。
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -111,6 +179,11 @@ export interface AuditLog {
|
||||
* 列表 StatusBadge 显示 "--" 占位;契约补齐后切换为真实值。
|
||||
*/
|
||||
status?: string | null;
|
||||
/**
|
||||
* 错误信息(status=failure/error 时有值)。
|
||||
* @contract-pending schema 未提供此字段,MSW 兜底;详情对话框展示用。
|
||||
*/
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogFilter {
|
||||
@@ -126,9 +199,25 @@ export interface InvitationCode {
|
||||
id: string;
|
||||
code: string;
|
||||
role: string;
|
||||
/** 角色展示名(@contract-pending,MSW 兜底) */
|
||||
roleName?: string | null;
|
||||
/** 关联班级 ID(@contract-pending,可选) */
|
||||
classId?: string | null;
|
||||
/** 关联班级名称(@contract-pending,可选) */
|
||||
className?: string | null;
|
||||
/** 关联邮箱(@contract-pending,可选) */
|
||||
email?: string | null;
|
||||
/** 批次 ID(@contract-pending,MSW 兜底) */
|
||||
batchId?: string | null;
|
||||
status: string;
|
||||
usedCount: number;
|
||||
maxUses: number;
|
||||
/** 使用者用户 ID(@contract-pending,MSW 兜底) */
|
||||
usedBy?: string | null;
|
||||
/** 使用者姓名(@contract-pending,MSW 兜底) */
|
||||
usedByName?: string | null;
|
||||
/** 使用时间 ISO(@contract-pending,MSW 兜底) */
|
||||
usedAt?: string | null;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
@@ -145,6 +234,51 @@ export type CreatedInvitationCode = Pick<
|
||||
"id" | "code" | "role" | "maxUses" | "expiresAt"
|
||||
>;
|
||||
|
||||
/**
|
||||
* 批量生成邀请码输入(对齐 CICD GenerateInvitationCodesInput)。
|
||||
*
|
||||
* 字段说明:
|
||||
* - batchName:批次名称(可选,用于标识本次生成用途)
|
||||
* - count:生成数量(1-1000)
|
||||
* - role:角色代码(teacher/student/parent/admin)
|
||||
* - expireDays:过期天数(与 expiresAt 二选一,expireDays 优先)
|
||||
* - expiresAt:ISO 过期时间(与 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-pending,MSW 兜底) */
|
||||
batchId?: string;
|
||||
generated: GeneratedInvitationCode[];
|
||||
}
|
||||
|
||||
/** 批量删除邀请码结果(@contract-pending,MSW 兜底) */
|
||||
export interface DeleteInvitationCodesResult {
|
||||
success: boolean;
|
||||
deletedCount: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types: School
|
||||
// ============================================================
|
||||
@@ -169,6 +303,8 @@ export interface SchoolInput {
|
||||
currentTerm?: string;
|
||||
semesterStart?: string;
|
||||
semesterEnd?: string;
|
||||
/** 学校编码(@contract-pending,MSW 兜底) */
|
||||
code?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -252,6 +388,18 @@ export function useUsers(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 单查用户详情(@contract-pending:schema 无 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(): {
|
||||
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
|
||||
loading: boolean;
|
||||
@@ -308,6 +456,101 @@ export function useUpdateUserRole(): {
|
||||
return { run, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(@contract-pending:MSW 兜底,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-pending:MSW 兜底,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-pending:schema 无 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
|
||||
// ============================================================
|
||||
@@ -450,6 +693,69 @@ export function useRevokeInvitationCode(): {
|
||||
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-pending,MSW 兜底)。
|
||||
* 对齐 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
|
||||
// ============================================================
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
GET_ATTENDANCE_REPORT_DOC,
|
||||
GET_ATTENDANCE_SHEET_DOC,
|
||||
GET_ATTENDANCE_STATS_DOC,
|
||||
GET_CLASS_COMPARISON_DOC,
|
||||
SAVE_ATTENDANCE_SHEET_DOC,
|
||||
} from "./operations/attendance.graphql";
|
||||
import type { UseQueryResult } from "./types";
|
||||
@@ -382,3 +383,38 @@ export function useSaveAttendanceSheet(): {
|
||||
|
||||
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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,12 +18,23 @@
|
||||
import type { FetchPolicy } from "@apollo/client";
|
||||
|
||||
import { useWidgetQuery } from "../useWidgetQuery";
|
||||
import { useWidgetMutation } from "../useWidgetMutation";
|
||||
import { ApiError } from "./errors";
|
||||
import {
|
||||
CREATE_CLASS_DOC,
|
||||
CREATE_CLASS_SCHEDULE_DOC,
|
||||
DELETE_CLASS_SCHEDULE_DOC,
|
||||
GENERATE_CLASS_INVITATION_CODE_DOC,
|
||||
GET_CLASS_INFO_DOC,
|
||||
GET_CLASS_INVITATION_CODES_DOC,
|
||||
GET_CLASS_SCHEDULE_DOC,
|
||||
GET_CLASS_STUDENTS_DOC,
|
||||
GET_CLASS_TEACHERS_DOC,
|
||||
GET_CLASSES_DOC,
|
||||
REVOKE_CLASS_INVITATION_CODE_DOC,
|
||||
UPDATE_CLASS_DOC,
|
||||
DELETE_CLASS_DOC,
|
||||
UPDATE_CLASS_SCHEDULE_DOC,
|
||||
} from "./operations/classes.graphql";
|
||||
import type { UseQueryResult } from "./types";
|
||||
|
||||
@@ -318,3 +329,374 @@ export function useClassTeachers(
|
||||
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-pending,MSW 兜底)=====
|
||||
|
||||
/**
|
||||
* 查询班级课表条目列表(@contract-pending,MSW 兜底)。
|
||||
* 复用 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-pending,MSW 兜底)。
|
||||
*/
|
||||
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-pending,MSW 兜底)。
|
||||
*/
|
||||
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-pending,MSW 兜底)。
|
||||
*/
|
||||
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-pending,MSW 兜底)=====
|
||||
|
||||
/**
|
||||
* 查询班级邀请码列表(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*/
|
||||
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-pending,MSW 兜底)。
|
||||
*/
|
||||
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-pending,MSW 兜底)=====
|
||||
|
||||
/**
|
||||
* 创建班级(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*/
|
||||
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-pending,MSW 兜底)。
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ApiError } from "./errors";
|
||||
import {
|
||||
CREATE_COURSE_PLAN_DOC,
|
||||
GET_COURSE_PLAN_DOC,
|
||||
GET_COURSE_PLAN_TEMPLATES_DOC,
|
||||
GET_COURSE_PLANS_DOC,
|
||||
UPDATE_COURSE_PLAN_DOC,
|
||||
} from "./operations/course-plans.graphql";
|
||||
@@ -77,6 +78,20 @@ export interface CoursePlanListItem {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程计划模板(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* 用于 create 页"从模板创建"对话框。模板是可复用的课程计划骨架,
|
||||
* 字段最小化(不含 units / progress 等详情字段)。
|
||||
*/
|
||||
export interface CoursePlanTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
subject: string;
|
||||
gradeLevel: string;
|
||||
}
|
||||
|
||||
/** 列表查询响应(@contract-pending 假契约形状,MSW 返回此结构) */
|
||||
interface CoursePlansListResponse {
|
||||
coursePlans: {
|
||||
@@ -90,6 +105,11 @@ interface CoursePlanResponse {
|
||||
coursePlan: CoursePlanDetail | null;
|
||||
}
|
||||
|
||||
/** 模板列表查询响应(@contract-pending,MSW 返回此结构) */
|
||||
interface CoursePlanTemplatesResponse {
|
||||
coursePlanTemplates: CoursePlanTemplate[];
|
||||
}
|
||||
|
||||
/** 创建课程计划输入 */
|
||||
export interface CreateCoursePlanInput {
|
||||
name: string;
|
||||
@@ -130,6 +150,12 @@ export interface CoursePlansListFilter {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** 模板查询筛选(按学科 / 年级过滤,均可选) */
|
||||
export interface CoursePlanTemplatesFilter {
|
||||
subjectId?: string;
|
||||
gradeLevel?: string;
|
||||
}
|
||||
|
||||
// ===== 查询选项 =====
|
||||
|
||||
export interface CoursePlanQueryOptions {
|
||||
@@ -214,6 +240,41 @@ export function useCoursePlan(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询课程计划模板列表(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
|
||||
@@ -20,7 +20,10 @@ import type { FetchPolicy } from "@apollo/client";
|
||||
|
||||
import { useWidgetQuery } from "../useWidgetQuery";
|
||||
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";
|
||||
|
||||
// ===== 数据类型(对齐 data-ana 子图,snake_case)=====
|
||||
@@ -149,3 +152,309 @@ export function useTeacherErrorBookStats(
|
||||
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 Client,CICD 使用 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 };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useWidgetQuery } from "../useWidgetQuery";
|
||||
import { ApiError } from "./errors";
|
||||
import {
|
||||
CREATE_EXAM_DOC,
|
||||
DELETE_EXAM_DOC,
|
||||
DUPLICATE_EXAM_DOC,
|
||||
GET_EXAM_ANALYTICS_DOC,
|
||||
GET_EXAM_BUILD_DOC,
|
||||
GET_EXAM_DOC,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
GET_QUESTIONS_LIBRARY_DOC,
|
||||
SAVE_EXAM_BUILD_DOC,
|
||||
SAVE_EXAM_RICH_CONTENT_DOC,
|
||||
UPDATE_EXAM_STATUS_DOC,
|
||||
} from "./operations/exams.graphql";
|
||||
import type { UseQueryResult } from "./types";
|
||||
|
||||
@@ -542,3 +545,118 @@ export function useSaveExamRichContent(): {
|
||||
|
||||
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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 用于发布/归档/恢复草稿等状态流转。
|
||||
* 后端补齐 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 复制考试基本信息生成新考试(不含题目结构)。
|
||||
* 后端补齐 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 };
|
||||
}
|
||||
|
||||
@@ -22,12 +22,25 @@ import { useWidgetMutation } from "../useWidgetMutation";
|
||||
import { useWidgetQuery } from "../useWidgetQuery";
|
||||
import { ApiError } from "./errors";
|
||||
import {
|
||||
ACQUIRE_DRAFT_LOCK_DOC,
|
||||
BATCH_CREATE_GRADE_RECORDS_BY_EXAM_DOC,
|
||||
BULK_DELETE_GRADES_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_DOC,
|
||||
GET_GRADES_LIST_DOC,
|
||||
GET_GRADE_STATS_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";
|
||||
import type { UseQueryResult } from "./types";
|
||||
|
||||
@@ -424,3 +437,647 @@ export function useReportCard(
|
||||
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-pending,MSW 兜底)。
|
||||
*
|
||||
* 注:命名为 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 注:命名为 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底) */
|
||||
classes?: number;
|
||||
/** 全校学生总数(@contract-pending,MSW 兜底) */
|
||||
students?: number;
|
||||
/** 整体平均分(@contract-pending,MSW 兜底) */
|
||||
overallAvg?: number;
|
||||
/** 最新一次作业平均分(@contract-pending,MSW 兜底) */
|
||||
latestAvg?: number;
|
||||
}
|
||||
|
||||
/** 班级排名项(年级洞察页 classRankings) */
|
||||
export interface GradeInsightsClassRanking {
|
||||
classId: string;
|
||||
className: string;
|
||||
averageScore: number;
|
||||
passRate: number;
|
||||
studentCount: number;
|
||||
rank: number;
|
||||
delta: number;
|
||||
/** 上次平均分(@contract-pending,MSW 兜底) */
|
||||
prevAvg?: number;
|
||||
/** 整体平均分(@contract-pending,MSW 兜底) */
|
||||
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-pending,MSW 兜底) */
|
||||
targeted?: number;
|
||||
/** 已批改人数(@contract-pending,MSW 兜底) */
|
||||
graded?: number;
|
||||
/** 中位数(@contract-pending,MSW 兜底) */
|
||||
median?: number;
|
||||
}
|
||||
|
||||
/** 全校年级成绩洞察聚合结果 */
|
||||
export interface SchoolWideGradeInsights {
|
||||
overallStats: SchoolWideOverallStats;
|
||||
grades: GradeInsightsGrade[];
|
||||
recentAssignments: GradeInsightsAssignment[];
|
||||
}
|
||||
|
||||
/** SchoolWideGradeSummary 查询响应 */
|
||||
interface SchoolWideGradeInsightsResponse {
|
||||
schoolWideGradeSummary: SchoolWideGradeInsights | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询全校年级成绩洞察(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* 用于 /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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,15 +19,21 @@ import { useWidgetQuery } from "../useWidgetQuery";
|
||||
import { ApiError } from "./errors";
|
||||
import {
|
||||
ASSIGN_HOMEWORK_DOC,
|
||||
BATCH_AUTO_GRADE_DOC,
|
||||
DELETE_SCAN_DOC,
|
||||
GET_AI_BATCH_GRADING_DOC,
|
||||
GET_ASSIGNMENT_SUBMISSIONS_DOC,
|
||||
GET_HOMEWORK_DOC,
|
||||
GET_HOMEWORK_LIST_DOC,
|
||||
GET_HOMEWORK_SUBMISSIONS_DOC,
|
||||
GET_SCANS_DOC,
|
||||
GET_SUBMISSION_DETAIL_DOC,
|
||||
GRADE_SUBMISSION_DOC,
|
||||
RECORD_GRADE_DOC,
|
||||
SAVE_HOMEWORK_ANSWER_DOC,
|
||||
SAVE_SCAN_GRADING_DOC,
|
||||
START_HOMEWORK_SUBMISSION_DOC,
|
||||
SUBMIT_HOMEWORK_DOC,
|
||||
} from "./operations/homework.graphql";
|
||||
import type { UseQueryResult } from "./types";
|
||||
|
||||
@@ -610,3 +616,295 @@ export function useRecordGrade(): {
|
||||
|
||||
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-pending,MSW 兜底)。
|
||||
*
|
||||
* 学生进入作答页点击"开始作答"时调用,创建一条 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 学生作答过程中(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-pending,MSW 兜底)。
|
||||
*
|
||||
* 学生点击"提交全部"后锁定提交并触发服务端自动判分。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 学生在作答页删除已上传的扫描图。
|
||||
*
|
||||
* 关联: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-pending,MSW 兜底)。
|
||||
*
|
||||
* 教师在按作业批量批改页一键自动批改多份提交的客观题。
|
||||
*
|
||||
* 关联: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
Reference in New Issue
Block a user