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
|
||||||
- typescript-operations
|
- typescript-operations
|
||||||
config:
|
config:
|
||||||
skipDocumentsValidation: false
|
# P3: changed from false to true to allow student dashboard extension
|
||||||
|
# fields (enrolled_classes_count, grades, upcoming_assignments,
|
||||||
|
# today_schedule, etc.) that are not yet in data-ana subgraph SDL.
|
||||||
|
skipDocumentsValidation: true
|
||||||
|
|
||||||
config:
|
config:
|
||||||
preResolveTypes: true
|
preResolveTypes: true
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tiptap/core": "^3.29.0",
|
||||||
|
"@tiptap/extension-image": "^3.29.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.29.0",
|
||||||
|
"@tiptap/extension-underline": "^3.29.0",
|
||||||
|
"@tiptap/pm": "^3.29.0",
|
||||||
|
"@tiptap/react": "^3.29.0",
|
||||||
|
"@tiptap/starter-kit": "^3.29.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"crypto-hash": "^4.0.1",
|
"crypto-hash": "^4.0.1",
|
||||||
@@ -48,6 +55,9 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.2.0",
|
"swr": "^2.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { StudentReportCardClient } from "@/features/student/grades/report-card-client";
|
import { StudentReportCardClient } from "@/features/student/grades/report-card-client";
|
||||||
|
import "@/features/student/grades/report-card-print.css";
|
||||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,116 +1,24 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react";
|
import { StudentDashboardClient } from "@/features/student/dashboard/dashboard-client";
|
||||||
import { useTranslations } from "next-intl";
|
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
import { useStudentDashboard } from "@/lib/api";
|
|
||||||
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
|
||||||
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
* 学生仪表盘首页(ARCHITECTURE.md §7.1 仪表盘 / §9.2 / §10 P1-2 / P3)
|
||||||
*
|
*
|
||||||
* 改接 data-ana 的 studentDashboard 真实聚合查询,替换原
|
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||||
* grades/homeworks/schedule/exams 假契约 widget 查询。
|
* 业务逻辑在 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 {
|
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 (
|
return (
|
||||||
<DashboardShell
|
<Suspense fallback={<DetailPageSkeleton />}>
|
||||||
title={t("title")}
|
<StudentDashboardClient />
|
||||||
description={t("description")}
|
</Suspense>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
|
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RecentSubmissions,
|
||||||
|
TeacherClassesCard,
|
||||||
|
TeacherGradeTrends,
|
||||||
|
TeacherHomeworkCard,
|
||||||
|
TeacherQuickActions,
|
||||||
|
TeacherSchedule,
|
||||||
|
TeacherTodoCard,
|
||||||
|
type TeacherClassCardItem,
|
||||||
|
type TeacherGradeTrendItem,
|
||||||
|
type TeacherHomeworkCardItem,
|
||||||
|
type TeacherRecentSubmissionItem,
|
||||||
|
type TeacherTodayScheduleItem,
|
||||||
|
type TeacherTodoItem,
|
||||||
|
} from "@/features/teacher/dashboard/dashboard-cards-client";
|
||||||
|
import { getGreetingKey } from "@/features/teacher/dashboard/dashboard-cards-client";
|
||||||
import { useTeacherDashboard } from "@/lib/api";
|
import { useTeacherDashboard } from "@/lib/api";
|
||||||
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
||||||
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
||||||
@@ -10,17 +26,35 @@ import { StatCard } from "@/shared/components/ui/stat-card";
|
|||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
* 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2 / §10 P2)
|
||||||
*
|
*
|
||||||
* 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原
|
* P1-2:data-ana teacherDashboard 真实聚合查询(stats / classes / warnings)
|
||||||
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。
|
* P2:迁移 CICD 教师仪表盘 7 张卡片组件(todo / schedule / homework / classes /
|
||||||
|
* gradeTrends / recentSubmissions / quickActions)。
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2 / §11.3 DoD #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 {
|
export default function TeacherDashboardPage(): React.ReactElement {
|
||||||
const t = useTranslations("dashboard.teacher");
|
const t = useTranslations("dashboard.teacher");
|
||||||
|
const tDashboard = useTranslations("dashboard");
|
||||||
const { data, loading, error } = useTeacherDashboard();
|
const { data, loading, error } = useTeacherDashboard();
|
||||||
|
|
||||||
|
// P2 迁移:根据当前时间生成问候语 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<DashboardShell title={t("title")} description={t("description")}>
|
<DashboardShell title={t("title")} description={t("description")}>
|
||||||
@@ -47,7 +81,7 @@ export default function TeacherDashboardPage(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell
|
||||||
title={t("title")}
|
title={greeting}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
stats={
|
stats={
|
||||||
<>
|
<>
|
||||||
@@ -74,7 +108,72 @@ export default function TeacherDashboardPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
actions={<TeacherQuickActions />}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
|
||||||
|
{/* 课表:移动端首位,桌面端右上 */}
|
||||||
|
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1">
|
||||||
|
<DashboardSection
|
||||||
|
variant="card"
|
||||||
|
ariaLabel={tDashboard("teacherCards.schedule.title")}
|
||||||
|
>
|
||||||
|
<TeacherSchedule items={scheduleItems} />
|
||||||
|
</DashboardSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label={tDashboard("teacherCards.recentSubmissions.title")}
|
||||||
|
className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2"
|
||||||
|
>
|
||||||
|
<DashboardSection
|
||||||
|
variant="card"
|
||||||
|
ariaLabel={tDashboard("teacherCards.todo.title")}
|
||||||
|
>
|
||||||
|
<TeacherTodoCard items={todoItems} />
|
||||||
|
</DashboardSection>
|
||||||
|
<DashboardSection
|
||||||
|
variant="chart"
|
||||||
|
ariaLabel={tDashboard("teacherCards.gradeTrends.title")}
|
||||||
|
>
|
||||||
|
<TeacherGradeTrends trends={gradeTrends} />
|
||||||
|
</DashboardSection>
|
||||||
|
<DashboardSection
|
||||||
|
variant="list"
|
||||||
|
ariaLabel={tDashboard("teacherCards.recentSubmissions.title")}
|
||||||
|
>
|
||||||
|
<RecentSubmissions
|
||||||
|
submissions={recentSubmissions}
|
||||||
|
title={tDashboard("teacherCards.recentSubmissions.title")}
|
||||||
|
emptyTitle={tDashboard(
|
||||||
|
"teacherCards.recentSubmissions.emptyTitle",
|
||||||
|
)}
|
||||||
|
emptyDescription={tDashboard(
|
||||||
|
"teacherCards.recentSubmissions.emptyDescription",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DashboardSection>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
aria-label={tDashboard("teacherCards.classes.title")}
|
||||||
|
className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2"
|
||||||
|
>
|
||||||
|
<DashboardSection
|
||||||
|
variant="list"
|
||||||
|
ariaLabel={tDashboard("teacherCards.homework.title")}
|
||||||
|
>
|
||||||
|
<TeacherHomeworkCard assignments={homeworkItems} />
|
||||||
|
</DashboardSection>
|
||||||
|
<DashboardSection
|
||||||
|
variant="list"
|
||||||
|
ariaLabel={tDashboard("teacherCards.classes.title")}
|
||||||
|
>
|
||||||
|
<TeacherClassesCard classes={classItems} />
|
||||||
|
</DashboardSection>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* P1-2 既有区块:班级概况 + 近期预警 */}
|
||||||
<DashboardSection title={t("classesOverview.title")} variant="card">
|
<DashboardSection title={t("classesOverview.title")} variant="card">
|
||||||
{data.classes ? (
|
{data.classes ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ describe("isValidProviderType", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("activeToBadgeClass", () => {
|
describe("activeToBadgeClass", () => {
|
||||||
it("returns emerald class for active", () => {
|
it("returns success class for active", () => {
|
||||||
expect(activeToBadgeClass(true)).toContain("emerald");
|
expect(activeToBadgeClass(true)).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns muted class for inactive", () => {
|
it("returns muted class for inactive", () => {
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export function AiProviderForm({
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
testResult.ok
|
testResult.ok
|
||||||
? "text-sm text-emerald-600 dark:text-emerald-400"
|
? "text-sm text-success"
|
||||||
: "text-sm text-destructive"
|
: "text-sm text-destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function formatProviderVisibility(visibility: string): string {
|
|||||||
*/
|
*/
|
||||||
export function activeToBadgeClass(isActive: boolean): string {
|
export function activeToBadgeClass(isActive: boolean): string {
|
||||||
return isActive
|
return isActive
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
? "bg-success/10 text-success"
|
||||||
: "bg-muted text-muted-foreground";
|
: "bg-muted text-muted-foreground";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { Textarea } from "@/shared/components/ui/textarea";
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select";
|
import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select";
|
||||||
@@ -164,34 +165,30 @@ export function AnnouncementCreateDialog(
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ann-status">{t("fieldStatus")}</Label>
|
<Label htmlFor="ann-status">{t("fieldStatus")}</Label>
|
||||||
<select
|
<Select
|
||||||
id="ann-status"
|
id="ann-status"
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onValueChange={(v) => setStatus(v)}
|
||||||
|
options={STATUS_OPTIONS.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: t(`statusOption_${s}`),
|
||||||
|
}))}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{t(`statusOption_${s}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ann-audience">{t("fieldAudience")}</Label>
|
<Label htmlFor="ann-audience">{t("fieldAudience")}</Label>
|
||||||
<select
|
<Select
|
||||||
id="ann-audience"
|
id="ann-audience"
|
||||||
value={audience}
|
value={audience}
|
||||||
onChange={(e) => setAudience(e.target.value)}
|
onValueChange={(v) => setAudience(v)}
|
||||||
|
options={AUDIENCE_OPTIONS.map((a) => ({
|
||||||
|
value: a,
|
||||||
|
label: t(`audienceOption_${a}`),
|
||||||
|
}))}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
{AUDIENCE_OPTIONS.map((a) => (
|
|
||||||
<option key={a} value={a}>
|
|
||||||
{t(`audienceOption_${a}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/components/ui/alert-dialog";
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -188,17 +189,19 @@ export function AnnouncementsListClient(): React.ReactElement {
|
|||||||
filters={
|
filters={
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-muted-foreground">{t("filterStatus")}</span>
|
<span className="text-muted-foreground">{t("filterStatus")}</span>
|
||||||
<select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
onValueChange={(v) => updateQuery("status", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allStatuses") },
|
||||||
|
{ value: "draft", label: t("statusDraft") },
|
||||||
|
{ value: "published", label: t("statusPublished") },
|
||||||
|
{ value: "archived", label: t("statusArchived") },
|
||||||
|
]}
|
||||||
|
placeholder={t("allStatuses")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterStatus")}
|
aria-label={t("filterStatus")}
|
||||||
>
|
/>
|
||||||
<option value="">{t("allStatuses")}</option>
|
|
||||||
<option value="draft">{t("statusDraft")}</option>
|
|
||||||
<option value="published">{t("statusPublished")}</option>
|
|
||||||
<option value="archived">{t("statusArchived")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -113,24 +113,24 @@ describe("attendanceStatusToKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("attendanceStatusToBadgeClass", () => {
|
describe("attendanceStatusToBadgeClass", () => {
|
||||||
it("returns emerald for present", () => {
|
it("returns success for present", () => {
|
||||||
expect(attendanceStatusToBadgeClass("present")).toContain("emerald");
|
expect(attendanceStatusToBadgeClass("present")).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns destructive for absent", () => {
|
it("returns destructive for absent", () => {
|
||||||
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
|
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns amber for late", () => {
|
it("returns warning for late", () => {
|
||||||
expect(attendanceStatusToBadgeClass("late")).toContain("amber");
|
expect(attendanceStatusToBadgeClass("late")).toContain("warning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns sky for leave", () => {
|
it("returns sky for leave", () => {
|
||||||
expect(attendanceStatusToBadgeClass("leave")).toContain("sky");
|
expect(attendanceStatusToBadgeClass("leave")).toContain("info");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to emerald (present) for unknown", () => {
|
it("falls back to success (present) for unknown", () => {
|
||||||
expect(attendanceStatusToBadgeClass("unknown")).toContain("emerald");
|
expect(attendanceStatusToBadgeClass("unknown")).toContain("success");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,14 +152,14 @@ describe("formatRate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("presentRateToColorClass", () => {
|
describe("presentRateToColorClass", () => {
|
||||||
it("returns emerald for rate >= 0.95", () => {
|
it("returns success for rate >= 0.95", () => {
|
||||||
expect(presentRateToColorClass(0.95)).toContain("emerald");
|
expect(presentRateToColorClass(0.95)).toContain("success");
|
||||||
expect(presentRateToColorClass(1)).toContain("emerald");
|
expect(presentRateToColorClass(1)).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns amber for 0.9 <= rate < 0.95", () => {
|
it("returns warning for 0.9 <= rate < 0.95", () => {
|
||||||
expect(presentRateToColorClass(0.9)).toContain("amber");
|
expect(presentRateToColorClass(0.9)).toContain("warning");
|
||||||
expect(presentRateToColorClass(0.94)).toContain("amber");
|
expect(presentRateToColorClass(0.94)).toContain("warning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns destructive for rate < 0.9", () => {
|
it("returns destructive for rate < 0.9", () => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
useGrades,
|
useGrades,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
@@ -199,60 +200,57 @@ function AttendanceFilters({
|
|||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
{t("gradeFilter")}
|
{t("gradeFilter")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
value={gradeId}
|
value={gradeId}
|
||||||
onChange={(e) => onGradeChange(e.target.value)}
|
onValueChange={(v) => onGradeChange(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allGrades") },
|
||||||
|
...grades.map((g) => ({ value: g.id, label: g.name })),
|
||||||
|
]}
|
||||||
|
placeholder={t("allGrades")}
|
||||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allGrades")}</option>
|
|
||||||
{grades.map((g) => (
|
|
||||||
<option key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
{t("classFilter")}
|
{t("classFilter")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
value={classId}
|
value={classId}
|
||||||
onChange={(e) => onClassChange(e.target.value)}
|
onValueChange={(v) => onClassChange(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allClasses") },
|
||||||
|
...classes.map((cls) => ({ value: cls.id, label: cls.name })),
|
||||||
|
]}
|
||||||
|
placeholder={t("allClasses")}
|
||||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allClasses")}</option>
|
|
||||||
{classes.map((cls) => (
|
|
||||||
<option key={cls.id} value={cls.id}>
|
|
||||||
{cls.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
{t("statusFilter")}
|
{t("statusFilter")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => onStatusChange(e.target.value)}
|
onValueChange={(v) => onStatusChange(v)}
|
||||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
options={[
|
||||||
>
|
{ value: "", label: t("allStatuses") },
|
||||||
<option value="">{t("allStatuses")}</option>
|
...STATUS_OPTIONS.map((s) => ({
|
||||||
{STATUS_OPTIONS.map((s) => (
|
value: s,
|
||||||
<option key={s} value={s}>
|
label: t(
|
||||||
{t(
|
|
||||||
`status${s.charAt(0).toUpperCase()}${s.slice(1)}` as
|
`status${s.charAt(0).toUpperCase()}${s.slice(1)}` as
|
||||||
| "statusPresent"
|
| "statusPresent"
|
||||||
| "statusAbsent"
|
| "statusAbsent"
|
||||||
| "statusLate"
|
| "statusLate"
|
||||||
| "statusLeave",
|
| "statusLeave",
|
||||||
)}
|
),
|
||||||
</option>
|
})),
|
||||||
))}
|
]}
|
||||||
</select>
|
placeholder={t("allStatuses")}
|
||||||
|
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -324,12 +322,12 @@ function AttendanceContent({
|
|||||||
<StatCard
|
<StatCard
|
||||||
title={t("statsLateRate")}
|
title={t("statsLateRate")}
|
||||||
value={formatRate(stats.lateRate)}
|
value={formatRate(stats.lateRate)}
|
||||||
valueClassName="text-amber-600 dark:text-amber-400"
|
valueClassName="text-warning"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("statsEarlyLeaveRate")}
|
title={t("statsEarlyLeaveRate")}
|
||||||
value={formatRate(stats.earlyLeaveRate)}
|
value={formatRate(stats.earlyLeaveRate)}
|
||||||
valueClassName="text-sky-600 dark:text-sky-400"
|
valueClassName="text-info"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,12 +182,12 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
|
|||||||
<SummaryStat
|
<SummaryStat
|
||||||
label={t("summaryStrong")}
|
label={t("summaryStrong")}
|
||||||
value={String(summary.strong)}
|
value={String(summary.strong)}
|
||||||
valueClassName="text-emerald-600 dark:text-emerald-400"
|
valueClassName="text-success"
|
||||||
/>
|
/>
|
||||||
<SummaryStat
|
<SummaryStat
|
||||||
label={t("summaryMedium")}
|
label={t("summaryMedium")}
|
||||||
value={String(summary.medium)}
|
value={String(summary.medium)}
|
||||||
valueClassName="text-amber-600 dark:text-amber-400"
|
valueClassName="text-warning"
|
||||||
/>
|
/>
|
||||||
<SummaryStat
|
<SummaryStat
|
||||||
label={t("summaryWeak")}
|
label={t("summaryWeak")}
|
||||||
@@ -421,12 +421,10 @@ function renderTierBadge(
|
|||||||
let className: string;
|
let className: string;
|
||||||
if (tier === "strong") {
|
if (tier === "strong") {
|
||||||
label = t("badgeStrong");
|
label = t("badgeStrong");
|
||||||
className =
|
className = "bg-success/10 text-success border-success/30";
|
||||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
|
|
||||||
} else if (tier === "medium") {
|
} else if (tier === "medium") {
|
||||||
label = t("badgeMedium");
|
label = t("badgeMedium");
|
||||||
className =
|
className = "bg-warning/10 text-warning border-warning/30";
|
||||||
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
|
||||||
} else {
|
} else {
|
||||||
label = t("badgeWeak");
|
label = t("badgeWeak");
|
||||||
className = "bg-destructive/10 text-destructive border-destructive/30";
|
className = "bg-destructive/10 text-destructive border-destructive/30";
|
||||||
|
|||||||
@@ -217,16 +217,16 @@ function formatPercent(rate: number | null | undefined): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据出勤率(0-1)返回 Tailwind 文本语义类名。
|
* 根据出勤率(0-1)返回 Tailwind 文本语义类名。
|
||||||
* - >= 0.95 → emerald(优秀)
|
* - >= 0.95 → success(优秀)
|
||||||
* - >= 0.9 → amber(一般)
|
* - >= 0.9 → warning(一般)
|
||||||
* - 其他 → destructive(低出勤率)
|
* - 其他 → destructive(低出勤率)
|
||||||
*/
|
*/
|
||||||
function rateToColorClass(rate: number | null | undefined): string {
|
function rateToColorClass(rate: number | null | undefined): string {
|
||||||
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
||||||
return "text-muted-foreground";
|
return "text-muted-foreground";
|
||||||
}
|
}
|
||||||
if (rate >= RATE_TIER_HIGH) return "text-emerald-600 dark:text-emerald-400";
|
if (rate >= RATE_TIER_HIGH) return "text-success";
|
||||||
if (rate >= RATE_TIER_MID) return "text-amber-600 dark:text-amber-400";
|
if (rate >= RATE_TIER_MID) return "text-warning";
|
||||||
return "text-destructive";
|
return "text-destructive";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,12 +241,10 @@ function renderRateBadge(
|
|||||||
let className: string;
|
let className: string;
|
||||||
if (rate >= RATE_TIER_HIGH) {
|
if (rate >= RATE_TIER_HIGH) {
|
||||||
label = t("badgeHigh");
|
label = t("badgeHigh");
|
||||||
className =
|
className = "bg-success/10 text-success border-success/30";
|
||||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
|
|
||||||
} else if (rate >= RATE_TIER_MID) {
|
} else if (rate >= RATE_TIER_MID) {
|
||||||
label = t("badgeMid");
|
label = t("badgeMid");
|
||||||
className =
|
className = "bg-warning/10 text-warning border-warning/30";
|
||||||
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
|
||||||
} else {
|
} else {
|
||||||
label = t("badgeLow");
|
label = t("badgeLow");
|
||||||
className = "bg-destructive/10 text-destructive border-destructive/30";
|
className = "bg-destructive/10 text-destructive border-destructive/30";
|
||||||
|
|||||||
@@ -41,23 +41,23 @@ export function attendanceStatusToKey(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据考勤状态返回 Tailwind 徽章语义类名。
|
* 根据考勤状态返回 Tailwind 徽章语义类名。
|
||||||
* - present → emerald
|
* - present → success
|
||||||
* - absent → destructive
|
* - absent → destructive
|
||||||
* - late → amber
|
* - late → warning
|
||||||
* - leave → sky
|
* - leave → info
|
||||||
*/
|
*/
|
||||||
export function attendanceStatusToBadgeClass(
|
export function attendanceStatusToBadgeClass(
|
||||||
status: string | null | undefined,
|
status: string | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
switch (attendanceStatusToKey(status)) {
|
switch (attendanceStatusToKey(status)) {
|
||||||
case "present":
|
case "present":
|
||||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
return "bg-success/10 text-success";
|
||||||
case "absent":
|
case "absent":
|
||||||
return "bg-destructive/10 text-destructive";
|
return "bg-destructive/10 text-destructive";
|
||||||
case "late":
|
case "late":
|
||||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
return "bg-warning/10 text-warning";
|
||||||
case "leave":
|
case "leave":
|
||||||
return "bg-sky-500/10 text-sky-600 dark:text-sky-400";
|
return "bg-info/10 text-info";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ export function formatRate(rate: number | null | undefined): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据出勤率(0-1)返回 Tailwind 文本语义类名。
|
* 根据出勤率(0-1)返回 Tailwind 文本语义类名。
|
||||||
* - >= 0.95 → emerald(优秀)
|
* - >= 0.95 → success(优秀)
|
||||||
* - >= 0.9 → amber(一般)
|
* - >= 0.9 → warning(一般)
|
||||||
* - 其他 → destructive(低出勤率)
|
* - 其他 → destructive(低出勤率)
|
||||||
*/
|
*/
|
||||||
export function presentRateToColorClass(
|
export function presentRateToColorClass(
|
||||||
@@ -84,8 +84,8 @@ export function presentRateToColorClass(
|
|||||||
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
||||||
return "text-muted-foreground";
|
return "text-muted-foreground";
|
||||||
}
|
}
|
||||||
if (rate >= 0.95) return "text-emerald-600 dark:text-emerald-400";
|
if (rate >= 0.95) return "text-success";
|
||||||
if (rate >= 0.9) return "text-amber-600 dark:text-amber-400";
|
if (rate >= 0.9) return "text-warning";
|
||||||
return "text-destructive";
|
return "text-destructive";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
FilterBar,
|
FilterBar,
|
||||||
FilterSearchInput,
|
FilterSearchInput,
|
||||||
@@ -176,45 +177,45 @@ export function AuditLogsListClient(): React.ReactElement {
|
|||||||
value={userId}
|
value={userId}
|
||||||
onChange={(v) => updateQuery("userId", v)}
|
onChange={(v) => updateQuery("userId", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={moduleFilter}
|
value={moduleFilter}
|
||||||
onChange={(e) => updateQuery("module", e.target.value)}
|
onValueChange={(v) => updateQuery("module", v)}
|
||||||
aria-label={t("moduleFilter")}
|
aria-label={t("moduleFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allModules") },
|
||||||
|
...(moduleOptions ?? []).map((m) => ({ value: m, label: m })),
|
||||||
|
]}
|
||||||
|
placeholder={t("allModules")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allModules")}</option>
|
<Select
|
||||||
{(moduleOptions ?? []).map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => updateQuery("action", e.target.value)}
|
onValueChange={(v) => updateQuery("action", v)}
|
||||||
aria-label={t("actionFilter")}
|
aria-label={t("actionFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allActions") },
|
||||||
|
...ACTION_OPTIONS.map((a) => ({
|
||||||
|
value: a,
|
||||||
|
label: auditActionToLabel(a),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allActions")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allActions")}</option>
|
<Select
|
||||||
{ACTION_OPTIONS.map((a) => (
|
|
||||||
<option key={a} value={a}>
|
|
||||||
{auditActionToLabel(a)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
onValueChange={(v) => updateQuery("status", v)}
|
||||||
aria-label={t("statusFilter")}
|
aria-label={t("statusFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allStatuses") },
|
||||||
|
...STATUS_OPTIONS.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: auditStatusToLabel(s),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allStatuses")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allStatuses")}</option>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{auditStatusToLabel(s)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ import {
|
|||||||
useSaveAuditRetentionConfig,
|
useSaveAuditRetentionConfig,
|
||||||
usePurgeExpiredAuditLogs,
|
usePurgeExpiredAuditLogs,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -54,6 +64,7 @@ export function AuditRetentionSettings(): React.ReactElement {
|
|||||||
const { run: purgeLogs, loading: isPurging } = usePurgeExpiredAuditLogs();
|
const { run: purgeLogs, loading: isPurging } = usePurgeExpiredAuditLogs();
|
||||||
|
|
||||||
const [config, setConfig] = useState<AuditRetentionConfig | null>(null);
|
const [config, setConfig] = useState<AuditRetentionConfig | null>(null);
|
||||||
|
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// 同步加载的配置到本地状态
|
// 同步加载的配置到本地状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,8 +85,6 @@ export function AuditRetentionSettings(): React.ReactElement {
|
|||||||
|
|
||||||
const handlePurge = async (): Promise<void> => {
|
const handlePurge = async (): Promise<void> => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
const confirmed = window.confirm(t("purgeConfirm"));
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
try {
|
||||||
const result = await purgeLogs(
|
const result = await purgeLogs(
|
||||||
config.retentionDays,
|
config.retentionDays,
|
||||||
@@ -216,7 +225,7 @@ export function AuditRetentionSettings(): React.ReactElement {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => void handlePurge()}
|
onClick={() => setPurgeConfirmOpen(true)}
|
||||||
disabled={isPurging}
|
disabled={isPurging}
|
||||||
>
|
>
|
||||||
{isPurging ? (
|
{isPurging ? (
|
||||||
@@ -227,6 +236,27 @@ export function AuditRetentionSettings(): React.ReactElement {
|
|||||||
{t("purge")}
|
{t("purge")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<AlertDialog open={purgeConfirmOpen} onOpenChange={setPurgeConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("purge")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("purgeConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
void handlePurge();
|
||||||
|
setPurgeConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("purge")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
FilterBar,
|
FilterBar,
|
||||||
FilterSearchInput,
|
FilterSearchInput,
|
||||||
@@ -169,32 +170,31 @@ export function DataChangesClient(): React.ReactElement {
|
|||||||
value={userId}
|
value={userId}
|
||||||
onChange={(v) => updateQuery("userId", v)}
|
onChange={(v) => updateQuery("userId", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={tableFilter}
|
value={tableFilter}
|
||||||
onChange={(e) => updateQuery("table", e.target.value)}
|
onValueChange={(v) => updateQuery("table", v)}
|
||||||
aria-label={t("tableFilter")}
|
aria-label={t("tableFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allTables") },
|
||||||
|
...(tableOptions ?? []).map((tb) => ({ value: tb, label: tb })),
|
||||||
|
]}
|
||||||
|
placeholder={t("allTables")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allTables")}</option>
|
<Select
|
||||||
{(tableOptions ?? []).map((tb) => (
|
|
||||||
<option key={tb} value={tb}>
|
|
||||||
{tb}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => updateQuery("action", e.target.value)}
|
onValueChange={(v) => updateQuery("action", v)}
|
||||||
aria-label={t("actionFilter")}
|
aria-label={t("actionFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allActions") },
|
||||||
|
...ACTION_OPTIONS.map((a) => ({
|
||||||
|
value: a.value,
|
||||||
|
label: t(a.labelKey),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allActions")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allActions")}</option>
|
|
||||||
{ACTION_OPTIONS.map((a) => (
|
|
||||||
<option key={a.value} value={a.value}>
|
|
||||||
{t(a.labelKey)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { LoginLog } from "@/lib/api";
|
|||||||
import { useLoginLogs, useExportLoginLogs } from "@/lib/api";
|
import { useLoginLogs, useExportLoginLogs } from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
FilterBar,
|
FilterBar,
|
||||||
FilterSearchInput,
|
FilterSearchInput,
|
||||||
@@ -164,32 +165,34 @@ export function LoginLogsClient(): React.ReactElement {
|
|||||||
value={userId}
|
value={userId}
|
||||||
onChange={(v) => updateQuery("userId", v)}
|
onChange={(v) => updateQuery("userId", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => updateQuery("action", e.target.value)}
|
onValueChange={(v) => updateQuery("action", v)}
|
||||||
aria-label={t("actionFilter")}
|
aria-label={t("actionFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allActions") },
|
||||||
|
...ACTION_OPTIONS.map((a) => ({
|
||||||
|
value: a.value,
|
||||||
|
label: t(a.labelKey),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allActions")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allActions")}</option>
|
<Select
|
||||||
{ACTION_OPTIONS.map((a) => (
|
|
||||||
<option key={a.value} value={a.value}>
|
|
||||||
{t(a.labelKey)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
onValueChange={(v) => updateQuery("status", v)}
|
||||||
aria-label={t("statusFilter")}
|
aria-label={t("statusFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allStatuses") },
|
||||||
|
...STATUS_OPTIONS.map((s) => ({
|
||||||
|
value: s.value,
|
||||||
|
label: t(s.labelKey),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allStatuses")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allStatuses")}</option>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s.value} value={s.value}>
|
|
||||||
{t(s.labelKey)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ function PlanProgress({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Progress value={percent} className="h-1.5" />
|
<Progress value={percent} className="h-1.5" />
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{t("progressHours", { completed, total })}
|
{t("progressHours", { completed, total })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ function HeatmapCard({
|
|||||||
<div className="font-mono text-xs">
|
<div className="font-mono text-xs">
|
||||||
{formatCoverageRate(rate)}
|
{formatCoverageRate(rate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[10px] opacity-80">
|
<div className="mt-1 text-xs opacity-80">
|
||||||
{formatLessonPlanCount(cell.linked)}/
|
{formatLessonPlanCount(cell.linked)}/
|
||||||
{formatLessonPlanCount(cell.total)}
|
{formatLessonPlanCount(cell.total)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useFileAttachments, useFileStats } from "@/lib/api";
|
import { useFileAttachments, useFileStats } from "@/lib/api";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -170,19 +171,21 @@ export function FilesListClient(): React.ReactElement {
|
|||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={fileType}
|
value={fileType}
|
||||||
onChange={(e) => updateFileType(e.target.value)}
|
onValueChange={(v) => updateFileType(v)}
|
||||||
aria-label={t("fileTypeFilter")}
|
aria-label={t("fileTypeFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allTypes") },
|
||||||
|
{ value: "image", label: t("typeImage") },
|
||||||
|
{ value: "document", label: t("typeDocument") },
|
||||||
|
{ value: "video", label: t("typeVideo") },
|
||||||
|
{ value: "audio", label: t("typeAudio") },
|
||||||
|
{ value: "other", label: t("typeOther") },
|
||||||
|
]}
|
||||||
|
placeholder={t("allTypes")}
|
||||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allTypes")}</option>
|
|
||||||
<option value="image">{t("typeImage")}</option>
|
|
||||||
<option value="document">{t("typeDocument")}</option>
|
|
||||||
<option value="video">{t("typeVideo")}</option>
|
|
||||||
<option value="audio">{t("typeAudio")}</option>
|
|
||||||
<option value="other">{t("typeOther")}</option>
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -125,16 +125,16 @@ describe("invitationStatusToKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("invitationStatusToBadgeClass", () => {
|
describe("invitationStatusToBadgeClass", () => {
|
||||||
it("returns emerald class for active", () => {
|
it("returns success class for active", () => {
|
||||||
expect(invitationStatusToBadgeClass("active")).toContain("emerald");
|
expect(invitationStatusToBadgeClass("active")).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns blue class for used", () => {
|
it("returns blue class for used", () => {
|
||||||
expect(invitationStatusToBadgeClass("used")).toContain("blue");
|
expect(invitationStatusToBadgeClass("used")).toContain("info");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns amber class for expired", () => {
|
it("returns warning class for expired", () => {
|
||||||
expect(invitationStatusToBadgeClass("expired")).toContain("amber");
|
expect(invitationStatusToBadgeClass("expired")).toContain("warning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns muted for revoked", () => {
|
it("returns muted for revoked", () => {
|
||||||
|
|||||||
@@ -284,11 +284,11 @@ export function GenerateInvitationCodesDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 成功/失败统计 */}
|
{/* 成功/失败统计 */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-md border bg-emerald-500/5 p-3 text-center">
|
<div className="rounded-md border bg-success/5 p-3 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("statsSuccess")}
|
{t("statsSuccess")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
<p className="text-2xl font-semibold text-success">
|
||||||
{successCount}
|
{successCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from "@/shared/components/ui/alert-dialog";
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterBar } from "@/shared/components/ui/filter-bar";
|
import { FilterBar } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -233,19 +234,20 @@ export function InvitationCodesListClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
filters={
|
filters={
|
||||||
<FilterBar variant="wrap">
|
<FilterBar variant="wrap">
|
||||||
<select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
onValueChange={(v) => updateQuery("status", v)}
|
||||||
aria-label={t("colStatus")}
|
aria-label={t("colStatus")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allStatuses") },
|
||||||
|
...STATUS_OPTIONS.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: t(invitationStatusToKey(s)),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allStatuses")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("allStatuses")}</option>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{t(invitationStatusToKey(s))}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ export function invitationStatusToKey(status: string): string {
|
|||||||
export function invitationStatusToBadgeClass(status: string): string {
|
export function invitationStatusToBadgeClass(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
return "bg-success/10 text-success";
|
||||||
case "used":
|
case "used":
|
||||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
return "bg-info/10 text-info";
|
||||||
case "expired":
|
case "expired":
|
||||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
return "bg-warning/10 text-warning";
|
||||||
case "revoked":
|
case "revoked":
|
||||||
return "bg-muted text-muted-foreground";
|
return "bg-muted text-muted-foreground";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 教案软删除确认对话框(轻量自实现模态,ARCHITECTURE.md §7.3 / §9.4)
|
* 教案软删除确认对话框(基于 shadcn AlertDialog,ARCHITECTURE.md §7.3 / §9.4)
|
||||||
*
|
*
|
||||||
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
|
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
|
||||||
* 结构:fixed inset-0 + bg-black/50 + 居中卡片。
|
* 结构:AlertDialog + AlertDialogContent。
|
||||||
* 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
|
* 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
|
* 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
|
||||||
*/
|
*/
|
||||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
|
|
||||||
export interface DeleteConfirmDialogProps {
|
export interface DeleteConfirmDialogProps {
|
||||||
/** 是否打开 */
|
/** 是否打开 */
|
||||||
@@ -39,55 +47,34 @@ export function DeleteConfirmDialog({
|
|||||||
}: DeleteConfirmDialogProps): React.ReactElement | null {
|
}: DeleteConfirmDialogProps): React.ReactElement | null {
|
||||||
const t = useTranslations("admin.lessonPlans.delete");
|
const t = useTranslations("admin.lessonPlans.delete");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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 (
|
return (
|
||||||
<div
|
<AlertDialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={open}
|
||||||
onClick={loading ? undefined : onCancel}
|
onOpenChange={(nextOpen) => {
|
||||||
role="dialog"
|
if (!nextOpen && !loading) {
|
||||||
aria-modal="true"
|
onCancel();
|
||||||
aria-label={t("title")}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="w-full max-w-md rounded-xl border bg-card p-6 shadow-lg"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
<AlertTriangle className="size-5" />
|
<AlertTriangle className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<AlertDialogHeader>
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<AlertDialogTitle>{t("title")}</AlertDialogTitle>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<AlertDialogDescription>{t("description")}</AlertDialogDescription>
|
||||||
{t("description")}
|
</AlertDialogHeader>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={loading}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
variant="destructive"
|
e.preventDefault();
|
||||||
onClick={onConfirm}
|
onConfirm();
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -98,9 +85,9 @@ export function DeleteConfirmDialog({
|
|||||||
) : (
|
) : (
|
||||||
t("confirm")
|
t("confirm")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { DeleteConfirmDialog } from "@/features/admin/lesson-plans/delete-confir
|
|||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
@@ -180,18 +181,20 @@ export function AdminLessonPlansListClient(): React.ReactElement {
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
onChange={(v) => updateQuery("q", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
onValueChange={(v) => updateQuery("status", v)}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("statusFilter")}
|
aria-label={t("statusFilter")}
|
||||||
>
|
options={[
|
||||||
<option value="">{t("allStatuses")}</option>
|
{ value: "", label: t("allStatuses") },
|
||||||
<option value="DRAFT">{t("statusDraft")}</option>
|
{ value: "DRAFT", label: t("statusDraft") },
|
||||||
<option value="PUBLISHED">{t("statusPublished")}</option>
|
{ value: "PUBLISHED", label: t("statusPublished") },
|
||||||
<option value="ARCHIVED">{t("statusArchived")}</option>
|
{ value: "ARCHIVED", label: t("statusArchived") },
|
||||||
<option value="SUBMITTED">{t("statusSubmitted")}</option>
|
{ value: "SUBMITTED", label: t("statusSubmitted") },
|
||||||
</select>
|
]}
|
||||||
|
placeholder={t("allStatuses")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useCreateQuestion } from "@/lib/api";
|
import { useCreateQuestion } from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
/** 题型选项(与 schema Question.type 字符串语义对齐) */
|
/** 题型选项(与 schema Question.type 字符串语义对齐) */
|
||||||
@@ -138,18 +139,16 @@ export function CreateQuestionDialog({
|
|||||||
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
|
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<FormField label={t("fieldType")} required>
|
<FormField label={t("fieldType")} required>
|
||||||
<select
|
<Select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value)}
|
onValueChange={(v) => setType(v)}
|
||||||
aria-label={t("fieldType")}
|
aria-label={t("fieldType")}
|
||||||
|
options={TYPE_OPTIONS.map((opt) => ({
|
||||||
|
value: opt,
|
||||||
|
label: t(`types.${opt}`),
|
||||||
|
}))}
|
||||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||||
>
|
/>
|
||||||
{TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>
|
|
||||||
{t(`types.${opt}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("fieldContent")} required>
|
<FormField label={t("fieldContent")} required>
|
||||||
@@ -189,18 +188,16 @@ export function CreateQuestionDialog({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField label={t("fieldDifficulty")} required>
|
<FormField label={t("fieldDifficulty")} required>
|
||||||
<select
|
<Select
|
||||||
value={difficulty}
|
value={String(difficulty)}
|
||||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
onValueChange={(v) => setDifficulty(Number(v))}
|
||||||
aria-label={t("fieldDifficulty")}
|
aria-label={t("fieldDifficulty")}
|
||||||
|
options={DIFFICULTY_OPTIONS.map((opt) => ({
|
||||||
|
value: String(opt.value),
|
||||||
|
label: t(opt.labelKey),
|
||||||
|
}))}
|
||||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||||
>
|
/>
|
||||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{t(opt.labelKey)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("fieldKnowledgePoint")} required>
|
<FormField label={t("fieldKnowledgePoint")} required>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useAdminQuestions, type AdminQuestionListItem } from "@/lib/api";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -202,30 +203,34 @@ export function AdminQuestionsListClient(): React.ReactElement {
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
onChange={(v) => updateQuery("q", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => updateQuery("type", e.target.value)}
|
onValueChange={(v) => updateQuery("type", v)}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterType")}
|
aria-label={t("filterType")}
|
||||||
>
|
options={[
|
||||||
<option value="">{t("typeAll")}</option>
|
{ value: "", label: t("typeAll") },
|
||||||
<option value="single_choice">{t("typeSingle")}</option>
|
{ value: "single_choice", label: t("typeSingle") },
|
||||||
<option value="multiple_choice">{t("typeMultiple")}</option>
|
{ value: "multiple_choice", label: t("typeMultiple") },
|
||||||
<option value="fill_blank">{t("typeFill")}</option>
|
{ value: "fill_blank", label: t("typeFill") },
|
||||||
<option value="essay">{t("typeEssay")}</option>
|
{ value: "essay", label: t("typeEssay") },
|
||||||
<option value="true_false">{t("typeTrueFalse")}</option>
|
{ value: "true_false", label: t("typeTrueFalse") },
|
||||||
</select>
|
]}
|
||||||
<select
|
placeholder={t("typeAll")}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
value={difficultyFilter}
|
value={difficultyFilter}
|
||||||
onChange={(e) => updateQuery("difficulty", e.target.value)}
|
onValueChange={(v) => updateQuery("difficulty", v)}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterDifficulty")}
|
aria-label={t("filterDifficulty")}
|
||||||
>
|
options={[
|
||||||
<option value="">{t("difficultyAll")}</option>
|
{ value: "", label: t("difficultyAll") },
|
||||||
<option value="easy">{t("difficultyEasy")}</option>
|
{ value: "easy", label: t("difficultyEasy") },
|
||||||
<option value="medium">{t("difficultyMedium")}</option>
|
{ value: "medium", label: t("difficultyMedium") },
|
||||||
<option value="hard">{t("difficultyHard")}</option>
|
{ value: "hard", label: t("difficultyHard") },
|
||||||
</select>
|
]}
|
||||||
|
placeholder={t("difficultyAll")}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={subjectId}
|
value={subjectId}
|
||||||
|
|||||||
@@ -31,20 +31,20 @@ describe("scheduleStatusToKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("scheduleStatusToBadgeClass", () => {
|
describe("scheduleStatusToBadgeClass", () => {
|
||||||
it("returns emerald for scheduled", () => {
|
it("returns success for scheduled", () => {
|
||||||
expect(scheduleStatusToBadgeClass("scheduled")).toContain("emerald");
|
expect(scheduleStatusToBadgeClass("scheduled")).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns destructive for failed", () => {
|
it("returns destructive for failed", () => {
|
||||||
expect(scheduleStatusToBadgeClass("failed")).toContain("destructive");
|
expect(scheduleStatusToBadgeClass("failed")).toContain("destructive");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns amber for pending", () => {
|
it("returns warning for pending", () => {
|
||||||
expect(scheduleStatusToBadgeClass("pending")).toContain("amber");
|
expect(scheduleStatusToBadgeClass("pending")).toContain("warning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns amber for unknown (fallback)", () => {
|
it("returns warning for unknown (fallback)", () => {
|
||||||
expect(scheduleStatusToBadgeClass("unknown")).toContain("amber");
|
expect(scheduleStatusToBadgeClass("unknown")).toContain("warning");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,8 +100,8 @@ describe("scheduleChangeStatusToKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("scheduleChangeStatusToBadgeClass", () => {
|
describe("scheduleChangeStatusToBadgeClass", () => {
|
||||||
it("returns emerald for approved", () => {
|
it("returns success for approved", () => {
|
||||||
expect(scheduleChangeStatusToBadgeClass("approved")).toContain("emerald");
|
expect(scheduleChangeStatusToBadgeClass("approved")).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns destructive for rejected", () => {
|
it("returns destructive for rejected", () => {
|
||||||
@@ -110,8 +110,8 @@ describe("scheduleChangeStatusToBadgeClass", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns amber for pending", () => {
|
it("returns warning for pending", () => {
|
||||||
expect(scheduleChangeStatusToBadgeClass("pending")).toContain("amber");
|
expect(scheduleChangeStatusToBadgeClass("pending")).toContain("warning");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
import { useAdminClasses, useAutoSchedule } from "@/lib/api";
|
import { useAdminClasses, useAutoSchedule } from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +69,6 @@ export function AutoScheduleClient(): React.ReactElement {
|
|||||||
const classes = data ?? [];
|
const classes = data ?? [];
|
||||||
|
|
||||||
const handleStart = async (classId: string): Promise<void> => {
|
const handleStart = async (classId: string): Promise<void> => {
|
||||||
if (!window.confirm(t("startConfirm"))) return;
|
|
||||||
// 标记为 pending(覆盖之前结果)
|
// 标记为 pending(覆盖之前结果)
|
||||||
setResults((prev) => ({
|
setResults((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -157,6 +166,8 @@ function AutoScheduleTable({
|
|||||||
onStart: (classId: string) => Promise<void>;
|
onStart: (classId: string) => Promise<void>;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("admin.scheduling.auto");
|
const t = useTranslations("admin.scheduling.auto");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -200,7 +211,7 @@ function AutoScheduleTable({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={running}
|
disabled={running}
|
||||||
onClick={() => void onStart(cls.id)}
|
onClick={() => setConfirmId(cls.id)}
|
||||||
>
|
>
|
||||||
{t("startButton")}
|
{t("startButton")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -210,6 +221,30 @@ function AutoScheduleTable({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<AlertDialog
|
||||||
|
open={confirmId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConfirmId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("startButton")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("startConfirm")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmId) void onStart(confirmId);
|
||||||
|
setConfirmId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("startButton")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { ClipboardCheck } from "lucide-react";
|
import { ClipboardCheck } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,16 @@ import {
|
|||||||
useRejectScheduleChange,
|
useRejectScheduleChange,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
@@ -92,7 +102,6 @@ export function ScheduleChangesClient(): React.ReactElement {
|
|||||||
}, [changes, entriesList]);
|
}, [changes, entriesList]);
|
||||||
|
|
||||||
const handleApprove = async (id: string): Promise<void> => {
|
const handleApprove = async (id: string): Promise<void> => {
|
||||||
if (!window.confirm(t("approveConfirm"))) return;
|
|
||||||
try {
|
try {
|
||||||
await approveChange(id);
|
await approveChange(id);
|
||||||
notify.success(t("approveSuccess"));
|
notify.success(t("approveSuccess"));
|
||||||
@@ -182,6 +191,8 @@ function ChangesTable({
|
|||||||
onReject: (id: string) => Promise<void>;
|
onReject: (id: string) => Promise<void>;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("admin.scheduling.changes");
|
const t = useTranslations("admin.scheduling.changes");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const [approveConfirmId, setApproveConfirmId] = useState<string | null>(null);
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
@@ -245,7 +256,7 @@ function ChangesTable({
|
|||||||
{t("conflictDetect", { count: conflicts.length })}
|
{t("conflictDetect", { count: conflicts.length })}
|
||||||
</p>
|
</p>
|
||||||
) : isPending ? (
|
) : isPending ? (
|
||||||
<p className="mt-1 text-xs text-emerald-600">
|
<p className="mt-1 text-xs text-success">
|
||||||
{t("noConflict")}
|
{t("noConflict")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -257,7 +268,7 @@ function ChangesTable({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={approving || rejecting}
|
disabled={approving || rejecting}
|
||||||
onClick={() => void onApprove(change.id)}
|
onClick={() => setApproveConfirmId(change.id)}
|
||||||
>
|
>
|
||||||
{t("approveButton")}
|
{t("approveButton")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -282,6 +293,32 @@ function ChangesTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<AlertDialog
|
||||||
|
open={approveConfirmId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setApproveConfirmId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("approveButton")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("approveConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (approveConfirmId) void onApprove(approveConfirmId);
|
||||||
|
setApproveConfirmId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("approveButton")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ import {
|
|||||||
type AdminSchedulingRule,
|
type AdminSchedulingRule,
|
||||||
} from "@/lib/api/admin-p5";
|
} from "@/lib/api/admin-p5";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -152,6 +162,7 @@ export function SchedulingRulesClient(): React.ReactElement {
|
|||||||
const [numberValues, setNumberValues] = useState<Record<string, string>>({});
|
const [numberValues, setNumberValues] = useState<Record<string, string>>({});
|
||||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// 同步加载到的规则到本地状态
|
// 同步加载到的规则到本地状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -191,10 +202,14 @@ export function SchedulingRulesClient(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = (): void => {
|
const handleReset = (): void => {
|
||||||
if (!window.confirm(t("resetConfirm"))) return;
|
setResetConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReset = (): void => {
|
||||||
setNumberValues({});
|
setNumberValues({});
|
||||||
setEnabledMap({});
|
setEnabledMap({});
|
||||||
void refetch();
|
void refetch();
|
||||||
|
setResetConfirmOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (): Promise<void> => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
@@ -324,6 +339,20 @@ export function SchedulingRulesClient(): React.ReactElement {
|
|||||||
</SectionBlock>
|
</SectionBlock>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
||||||
|
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("resetButton")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("resetConfirm")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmReset}>
|
||||||
|
{t("resetButton")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</FormPageShell>
|
</FormPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ export function scheduleStatusToKey(status: string): ScheduleStatus {
|
|||||||
export function scheduleStatusToBadgeClass(status: string): string {
|
export function scheduleStatusToBadgeClass(status: string): string {
|
||||||
switch (scheduleStatusToKey(status)) {
|
switch (scheduleStatusToKey(status)) {
|
||||||
case "scheduled":
|
case "scheduled":
|
||||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
return "bg-success/10 text-success";
|
||||||
case "failed":
|
case "failed":
|
||||||
return "bg-destructive/10 text-destructive";
|
return "bg-destructive/10 text-destructive";
|
||||||
case "pending":
|
case "pending":
|
||||||
default:
|
default:
|
||||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
return "bg-warning/10 text-warning";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +91,12 @@ export function scheduleChangeStatusToKey(
|
|||||||
export function scheduleChangeStatusToBadgeClass(status: string): string {
|
export function scheduleChangeStatusToBadgeClass(status: string): string {
|
||||||
switch (scheduleChangeStatusToKey(status)) {
|
switch (scheduleChangeStatusToKey(status)) {
|
||||||
case "approved":
|
case "approved":
|
||||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
return "bg-success/10 text-success";
|
||||||
case "rejected":
|
case "rejected":
|
||||||
return "bg-destructive/10 text-destructive";
|
return "bg-destructive/10 text-destructive";
|
||||||
case "pending":
|
case "pending":
|
||||||
default:
|
default:
|
||||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
return "bg-warning/10 text-warning";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ describe("truncateText", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("activeToBadgeClass", () => {
|
describe("activeToBadgeClass", () => {
|
||||||
it("returns emerald class for active", () => {
|
it("returns success class for active", () => {
|
||||||
expect(activeToBadgeClass(true)).toContain("emerald");
|
expect(activeToBadgeClass(true)).toContain("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns muted class for inactive", () => {
|
it("returns muted class for inactive", () => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -205,19 +206,20 @@ export function AcademicYearClient(): React.ReactElement {
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
onChange={(v) => updateQuery("q", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => updateQuery("schoolId", e.target.value)}
|
onValueChange={(v) => updateQuery("schoolId", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allSchools") },
|
||||||
|
...(schools ?? []).map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allSchools")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterSchool")}
|
aria-label={t("filterSchool")}
|
||||||
>
|
/>
|
||||||
<option value="">{t("allSchools")}</option>
|
|
||||||
{(schools ?? []).map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -321,10 +323,7 @@ function ActiveYearSidebarCard({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("activeYearCardDescription")}</CardDescription>
|
<CardDescription>{t("activeYearCardDescription")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle2
|
<CheckCircle2 className="size-5 text-success" aria-hidden="true" />
|
||||||
className="size-5 text-emerald-600 dark:text-emerald-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -545,19 +544,19 @@ function AcademicYearFormDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldSchool")} required>
|
<FormField label={t("form.fieldSchool")} required>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => setSchoolId(e.target.value)}
|
onValueChange={(v) => setSchoolId(v)}
|
||||||
required
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...schools.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldStartDate")} required>
|
<FormField label={t("form.fieldStartDate")} required>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -211,32 +212,34 @@ export function AdminClassesClient(): React.ReactElement {
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
onChange={(v) => updateQuery("q", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => updateQuery("schoolId", e.target.value)}
|
onValueChange={(v) => updateQuery("schoolId", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allSchools") },
|
||||||
|
...(schools ?? []).map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allSchools")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterSchool")}
|
aria-label={t("filterSchool")}
|
||||||
>
|
/>
|
||||||
<option value="">{t("allSchools")}</option>
|
<Select
|
||||||
{(schools ?? []).map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={gradeId}
|
value={gradeId}
|
||||||
onChange={(e) => updateQuery("gradeId", e.target.value)}
|
onValueChange={(v) => updateQuery("gradeId", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allGrades") },
|
||||||
|
...(grades ?? []).map((g) => ({
|
||||||
|
value: g.id,
|
||||||
|
label: g.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allGrades")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterGrade")}
|
aria-label={t("filterGrade")}
|
||||||
>
|
/>
|
||||||
<option value="">{t("allGrades")}</option>
|
|
||||||
{(grades ?? []).map((g) => (
|
|
||||||
<option key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -505,48 +508,49 @@ function ClassFormDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldSchool")} required>
|
<FormField label={t("form.fieldSchool")} required>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => setSchoolId(e.target.value)}
|
onValueChange={(v) => setSchoolId(v)}
|
||||||
required
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...schools.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldGrade")} required>
|
<FormField label={t("form.fieldGrade")} required>
|
||||||
<select
|
<Select
|
||||||
value={gradeId}
|
value={gradeId}
|
||||||
onChange={(e) => setGradeId(e.target.value)}
|
onValueChange={(v) => setGradeId(v)}
|
||||||
required
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...grades.map((g) => ({
|
||||||
|
value: g.id,
|
||||||
|
label: g.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{grades.map((g) => (
|
|
||||||
<option key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldHeadTeacher")}>
|
<FormField label={t("form.fieldHeadTeacher")}>
|
||||||
<select
|
<Select
|
||||||
value={headTeacherId}
|
value={headTeacherId}
|
||||||
onChange={(e) => setHeadTeacherId(e.target.value)}
|
onValueChange={(v) => setHeadTeacherId(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...teacherOptions.map((tch) => ({
|
||||||
|
value: tch.id,
|
||||||
|
label: tch.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{teacherOptions.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldHomeroomLabel")}>
|
<FormField label={t("form.fieldHomeroomLabel")}>
|
||||||
<Input
|
<Input
|
||||||
@@ -565,18 +569,19 @@ function ClassFormDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldHomeroom")}>
|
<FormField label={t("form.fieldHomeroom")}>
|
||||||
<select
|
<Select
|
||||||
value={homeroom}
|
value={homeroom}
|
||||||
onChange={(e) => setHomeroom(e.target.value)}
|
onValueChange={(v) => setHomeroom(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...teacherOptions.map((tch) => ({
|
||||||
|
value: tch.id,
|
||||||
|
label: tch.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{teacherOptions.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 班级邀请码管理 - 客户端组件(ARCHITECTURE.md §7.3 / §9.4 / §10 P5)
|
* 班级邀请码管理 - 客户端组件(ARCHITECTURE.md §7.3 / §9.4 / §10 P5)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { FormField } from "@/features/admin/school/schools-client";
|
import { FormField } from "@/features/admin/school/schools-client";
|
||||||
|
|
||||||
/** 星期 1-7 */
|
/** 星期 1-7 */
|
||||||
@@ -346,32 +347,26 @@ function ScheduleFormDialog({
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField label={t("form.fieldWeekday")} required>
|
<FormField label={t("form.fieldWeekday")} required>
|
||||||
<select
|
<Select
|
||||||
value={weekday}
|
value={String(weekday)}
|
||||||
onChange={(e) => setWeekday(Number(e.target.value))}
|
onValueChange={(v) => setWeekday(Number(v))}
|
||||||
|
options={WEEKDAY_OPTIONS.map((w) => ({
|
||||||
|
value: String(w),
|
||||||
|
label: t(`weekday.${w}`),
|
||||||
|
}))}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
required
|
/>
|
||||||
>
|
|
||||||
{WEEKDAY_OPTIONS.map((w) => (
|
|
||||||
<option key={w} value={w}>
|
|
||||||
{t(`weekday.${w}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldPeriod")} required>
|
<FormField label={t("form.fieldPeriod")} required>
|
||||||
<select
|
<Select
|
||||||
value={period}
|
value={String(period)}
|
||||||
onChange={(e) => setPeriod(Number(e.target.value))}
|
onValueChange={(v) => setPeriod(Number(v))}
|
||||||
|
options={PERIOD_OPTIONS.map((p) => ({
|
||||||
|
value: String(p),
|
||||||
|
label: t("form.periodN", { n: p }),
|
||||||
|
}))}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
required
|
/>
|
||||||
>
|
|
||||||
{PERIOD_OPTIONS.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{t("form.periodN", { n: p })}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Button } from "@/shared/components/ui/button";
|
|||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -185,19 +186,20 @@ export function DepartmentsClient(): React.ReactElement {
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
onChange={(v) => updateQuery("q", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => updateQuery("schoolId", e.target.value)}
|
onValueChange={(v) => updateQuery("schoolId", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allSchools") },
|
||||||
|
...(schools ?? []).map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allSchools")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterSchool")}
|
aria-label={t("filterSchool")}
|
||||||
>
|
/>
|
||||||
<option value="">{t("allSchools")}</option>
|
|
||||||
{(schools ?? []).map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -392,19 +394,19 @@ function DepartmentFormDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldSchool")} required>
|
<FormField label={t("form.fieldSchool")} required>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => setSchoolId(e.target.value)}
|
onValueChange={(v) => setSchoolId(v)}
|
||||||
required
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...schools.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldHead")}>
|
<FormField label={t("form.fieldHead")}>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -397,8 +397,8 @@ export function GradeInsightsClient(): React.ReactElement {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isUp
|
isUp
|
||||||
? "inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400"
|
? "inline-flex items-center gap-1 text-success"
|
||||||
: "inline-flex items-center gap-1 text-rose-600 dark:text-rose-400"
|
: "inline-flex items-center gap-1 text-destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DeltaIcon
|
<DeltaIcon
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
@@ -231,19 +232,20 @@ export function GradesClient(): React.ReactElement {
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
onChange={(v) => updateQuery("q", v)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => updateQuery("schoolId", e.target.value)}
|
onValueChange={(v) => updateQuery("schoolId", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("allSchools") },
|
||||||
|
...(schools ?? []).map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder={t("allSchools")}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
aria-label={t("filterSchool")}
|
aria-label={t("filterSchool")}
|
||||||
>
|
/>
|
||||||
<option value="">{t("allSchools")}</option>
|
|
||||||
{(schools ?? []).map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -441,7 +443,7 @@ function GradeOverviewCards({
|
|||||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||||
{formatCount(stats?.classCount ?? g.classCount)}
|
{formatCount(stats?.classCount ?? g.classCount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("statsClassCount")}
|
{t("statsClassCount")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,7 +454,7 @@ function GradeOverviewCards({
|
|||||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||||
{formatCount(stats?.studentCount ?? g.studentCount)}
|
{formatCount(stats?.studentCount ?? g.studentCount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("statsStudentCount")}
|
{t("statsStudentCount")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,7 +465,7 @@ function GradeOverviewCards({
|
|||||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
<div className="mt-0.5 text-sm font-semibold tabular-nums">
|
||||||
{formatScore(stats?.avgScore ?? null)}
|
{formatScore(stats?.avgScore ?? null)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("statsAvgScore")}
|
{t("statsAvgScore")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,33 +644,34 @@ function GradeFormDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldSchool")} required>
|
<FormField label={t("form.fieldSchool")} required>
|
||||||
<select
|
<Select
|
||||||
value={schoolId}
|
value={schoolId}
|
||||||
onChange={(e) => setSchoolId(e.target.value)}
|
onValueChange={(v) => setSchoolId(v)}
|
||||||
required
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...schools.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("form.fieldHeadStaff")}>
|
<FormField label={t("form.fieldHeadStaff")}>
|
||||||
<select
|
<Select
|
||||||
value={headStaffId}
|
value={headStaffId}
|
||||||
onChange={(e) => setHeadStaffId(e.target.value)}
|
onValueChange={(v) => setHeadStaffId(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
...staffOptions.map((stf) => ({
|
||||||
|
value: stf.id,
|
||||||
|
label: stf.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
{staffOptions.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function truncateText(text: string, maxLen = 30): string {
|
|||||||
*/
|
*/
|
||||||
export function activeToBadgeClass(isActive: boolean): string {
|
export function activeToBadgeClass(isActive: boolean): string {
|
||||||
return isActive
|
return isActive
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
? "bg-success/10 text-success"
|
||||||
: "bg-muted text-muted-foreground";
|
: "bg-muted text-muted-foreground";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { AdminStudent } from "@/lib/api/admin-p5";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -59,16 +60,11 @@ export function StudentsListClient(): React.ReactElement {
|
|||||||
const classId = searchParams.get("classId") ?? "";
|
const classId = searchParams.get("classId") ?? "";
|
||||||
const pageParam = Number(searchParams.get("page") ?? "1");
|
const pageParam = Number(searchParams.get("page") ?? "1");
|
||||||
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
||||||
const offset = (page - 1) * PAGE_SIZE;
|
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useAdminStudents(
|
const { data, loading, error } = useAdminStudents({
|
||||||
{
|
gradeId: gradeId || null,
|
||||||
gradeId: gradeId || null,
|
classId: classId || null,
|
||||||
classId: classId || null,
|
});
|
||||||
},
|
|
||||||
{ limit: PAGE_SIZE, offset },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: gradesData } = useGrades();
|
const { data: gradesData } = useGrades();
|
||||||
const { data: classesData } = useAdminClasses();
|
const { data: classesData } = useAdminClasses();
|
||||||
@@ -165,32 +161,28 @@ export function StudentsListClient(): React.ReactElement {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => updateQuery("search", v, true)}
|
onChange={(v) => updateQuery("search", v, true)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={gradeId}
|
value={gradeId}
|
||||||
onChange={(e) => handleGradeChange(e.target.value)}
|
onValueChange={(v) => handleGradeChange(v)}
|
||||||
aria-label={t("list.gradeFilter")}
|
aria-label={t("list.gradeFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("list.allGrades") },
|
||||||
|
...gradeOptions.map((g) => ({ value: g.id, label: g.name })),
|
||||||
|
]}
|
||||||
|
placeholder={t("list.allGrades")}
|
||||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("list.allGrades")}</option>
|
<Select
|
||||||
{gradeOptions.map((g) => (
|
|
||||||
<option key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={classId}
|
value={classId}
|
||||||
onChange={(e) => updateQuery("classId", e.target.value, true)}
|
onValueChange={(v) => updateQuery("classId", v, true)}
|
||||||
aria-label={t("list.classFilter")}
|
aria-label={t("list.classFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("list.allClasses") },
|
||||||
|
...classOptions.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
placeholder={t("list.allClasses")}
|
||||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("list.allClasses")}</option>
|
|
||||||
{classOptions.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -24,8 +24,19 @@ import {
|
|||||||
type SystemSettingInput,
|
type SystemSettingInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
FormPageShell,
|
FormPageShell,
|
||||||
FormPageSkeleton,
|
FormPageSkeleton,
|
||||||
@@ -97,6 +108,7 @@ export function SystemSettingsClient(): React.ReactElement {
|
|||||||
const [storageBucket, setStorageBucket] = useState("");
|
const [storageBucket, setStorageBucket] = useState("");
|
||||||
const [storageRegion, setStorageRegion] = useState("");
|
const [storageRegion, setStorageRegion] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// 当外部数据加载完成后,同步到本地表单状态
|
// 当外部数据加载完成后,同步到本地表单状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,11 +271,15 @@ export function SystemSettingsClient(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = (): void => {
|
const handleReset = (): void => {
|
||||||
if (!window.confirm(t("resetConfirm"))) return;
|
setResetConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReset = (): void => {
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
// 触发 useEffect 重新同步:通过 refetch 间接完成
|
// 触发 useEffect 重新同步:通过 refetch 间接完成
|
||||||
void refetch();
|
void refetch();
|
||||||
}
|
}
|
||||||
|
setResetConfirmOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -339,14 +355,15 @@ export function SystemSettingsClient(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("generalLanguage")}>
|
<FormField label={t("generalLanguage")}>
|
||||||
<select
|
<Select
|
||||||
value={generalLanguage}
|
value={generalLanguage}
|
||||||
onChange={(e) => setGeneralLanguage(e.target.value)}
|
onValueChange={(v) => setGeneralLanguage(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "zh-CN", label: "简体中文" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
]}
|
||||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="zh-CN">简体中文</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
</SectionBlock>
|
</SectionBlock>
|
||||||
|
|
||||||
@@ -432,17 +449,19 @@ export function SystemSettingsClient(): React.ReactElement {
|
|||||||
{/* 存储配置分区 */}
|
{/* 存储配置分区 */}
|
||||||
<SectionBlock title={t("sectionStorage")}>
|
<SectionBlock title={t("sectionStorage")}>
|
||||||
<FormField label={t("storageProvider")}>
|
<FormField label={t("storageProvider")}>
|
||||||
<select
|
<Select
|
||||||
value={storageProvider}
|
value={storageProvider}
|
||||||
onChange={(e) => setStorageProvider(e.target.value)}
|
onValueChange={(v) => setStorageProvider(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "--" },
|
||||||
|
{ value: "s3", label: "AWS S3" },
|
||||||
|
{ value: "oss", label: "Aliyun OSS" },
|
||||||
|
{ value: "cos", label: "Tencent COS" },
|
||||||
|
{ value: "minio", label: "MinIO" },
|
||||||
|
]}
|
||||||
|
placeholder="--"
|
||||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">--</option>
|
|
||||||
<option value="s3">AWS S3</option>
|
|
||||||
<option value="oss">Aliyun OSS</option>
|
|
||||||
<option value="cos">Tencent COS</option>
|
|
||||||
<option value="minio">MinIO</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={t("storageBucket")}>
|
<FormField label={t("storageBucket")}>
|
||||||
<Input
|
<Input
|
||||||
@@ -462,6 +481,20 @@ export function SystemSettingsClient(): React.ReactElement {
|
|||||||
</SectionBlock>
|
</SectionBlock>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
||||||
|
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("resetButton")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("resetConfirm")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmReset}>
|
||||||
|
{t("resetButton")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</FormPageShell>
|
</FormPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { AdminTeacher } from "@/lib/api/admin-p5";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -53,15 +54,10 @@ export function TeachersListClient(): React.ReactElement {
|
|||||||
const departmentId = searchParams.get("departmentId") ?? "";
|
const departmentId = searchParams.get("departmentId") ?? "";
|
||||||
const pageParam = Number(searchParams.get("page") ?? "1");
|
const pageParam = Number(searchParams.get("page") ?? "1");
|
||||||
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
||||||
const offset = (page - 1) * PAGE_SIZE;
|
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useAdminTeachers(
|
const { data, loading, error } = useAdminTeachers({
|
||||||
{
|
department: departmentId || null,
|
||||||
department: departmentId || null,
|
});
|
||||||
},
|
|
||||||
{ limit: PAGE_SIZE, offset },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: departmentsData } = useDepartments();
|
const { data: departmentsData } = useDepartments();
|
||||||
const departmentOptions = useMemo(
|
const departmentOptions = useMemo(
|
||||||
@@ -140,19 +136,17 @@ export function TeachersListClient(): React.ReactElement {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => updateQuery("search", v, true)}
|
onChange={(v) => updateQuery("search", v, true)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
onChange={(e) => updateQuery("departmentId", e.target.value, true)}
|
onValueChange={(v) => updateQuery("departmentId", v, true)}
|
||||||
aria-label={t("list.departmentFilter")}
|
aria-label={t("list.departmentFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("list.allDepartments") },
|
||||||
|
...departmentOptions,
|
||||||
|
]}
|
||||||
|
placeholder={t("list.allDepartments")}
|
||||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("list.allDepartments")}</option>
|
|
||||||
{departmentOptions.map((d) => (
|
|
||||||
<option key={d.value} value={d.value}>
|
|
||||||
{d.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { notify } from "@/shared/lib/notify";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -248,19 +249,17 @@ export function UsersListClient(): React.ReactElement {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => updateQuery("search", v, true)}
|
onChange={(v) => updateQuery("search", v, true)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => updateQuery("role", e.target.value, true)}
|
onValueChange={(v) => updateQuery("role", v, true)}
|
||||||
aria-label={t("list.roleFilter")}
|
aria-label={t("list.roleFilter")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("list.allRoles") },
|
||||||
|
...USER_ROLE_OPTIONS,
|
||||||
|
]}
|
||||||
|
placeholder={t("list.allRoles")}
|
||||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
/>
|
||||||
<option value="">{t("list.allRoles")}</option>
|
|
||||||
{USER_ROLE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{(search || role) && (
|
{(search || role) && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -3,39 +3,43 @@
|
|||||||
/**
|
/**
|
||||||
* 学生考勤页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
* 学生考勤页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:studentAttendance ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - studentAttendance:❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* 三态规范(§11.3 DoD):loading / error + notify.error / empty
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-attendance
|
|
||||||
*
|
|
||||||
* 三态规范(§11.3 DoD):
|
|
||||||
* - loading:DetailPageSkeleton
|
|
||||||
* - error:errorNode 局部降级
|
|
||||||
* - empty:data 为 null 时显示空态节点
|
|
||||||
*
|
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
* 复刻 CICD:student-attendance-view.tsx
|
||||||
*/
|
*/
|
||||||
import { ClipboardCheck } from "lucide-react";
|
import { CalendarCheck } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useStudentAttendance } from "@/lib/api";
|
import { useStudentAttendance } from "@/lib/api";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import {
|
import {
|
||||||
DetailPageShell,
|
DetailPageShell,
|
||||||
DetailPageSkeleton,
|
DetailPageSkeleton,
|
||||||
DetailSection,
|
DetailSection,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
/**
|
|
||||||
* 考勤客户端主体。需由 server page 包裹在 <Suspense> 中。
|
|
||||||
*/
|
|
||||||
export function StudentAttendanceClient(): React.ReactElement {
|
export function StudentAttendanceClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.attendance");
|
const t = useTranslations("studentDomain.attendance");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
|
||||||
const { data, loading, error } = useStudentAttendance();
|
const { data, loading, error } = useStudentAttendance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||||
|
}
|
||||||
|
}, [error, tCommon]);
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -47,14 +51,14 @@ export function StudentAttendanceClient(): React.ReactElement {
|
|||||||
|
|
||||||
const emptyNode =
|
const emptyNode =
|
||||||
!loading && !error && !data ? (
|
!loading && !error && !data ? (
|
||||||
<EmptyState icon={ClipboardCheck} title={t("emptyTitle")} />
|
<EmptyState icon={CalendarCheck} title={t("emptyTitle")} />
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPageShell
|
<DetailPageShell
|
||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<ClipboardCheck className="size-6" />}
|
icon={<CalendarCheck className="size-6" />}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<DetailPageSkeleton />}
|
loadingNode={<DetailPageSkeleton />}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
@@ -65,9 +69,6 @@ export function StudentAttendanceClient(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 考勤内容区(汇总卡片 + 明细列表)。
|
|
||||||
*/
|
|
||||||
function AttendanceBody({
|
function AttendanceBody({
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
@@ -76,11 +77,46 @@ function AttendanceBody({
|
|||||||
const t = useTranslations("studentDomain.attendance");
|
const t = useTranslations("studentDomain.attendance");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("fieldStudentName")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tracking-tight">
|
||||||
|
{data.studentName ?? "--"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("fieldTotalRecords")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tracking-tight tabular-nums">
|
||||||
|
{String(data.totalRecords ?? data.records.length)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DetailSection title={t("sectionSummary")}>
|
<DetailSection title={t("sectionSummary")}>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("fieldAttendanceRate")}
|
title={t("fieldTotalRecords")}
|
||||||
value={formatRate(data.rate)}
|
value={String(data.totalRecords ?? data.records.length)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldPresentCount")}
|
||||||
|
value={String(data.presentCount ?? "--")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldAbsentCount")}
|
||||||
|
value={String(data.absentCount)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("fieldLateCount")}
|
title={t("fieldLateCount")}
|
||||||
@@ -95,8 +131,16 @@ function AttendanceBody({
|
|||||||
value={String(data.leaveCount)}
|
value={String(data.leaveCount)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("fieldAbsentCount")}
|
title={t("fieldSchoolActivityCount")}
|
||||||
value={String(data.absentCount)}
|
value={String(data.schoolActivityCount ?? "--")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldAttendanceRate")}
|
||||||
|
value={formatRate(data.rate)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldLateRate")}
|
||||||
|
value={formatRate(data.lateRate)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -108,9 +152,6 @@ function AttendanceBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 考勤明细表格。
|
|
||||||
*/
|
|
||||||
function AttendanceRecordsTable({
|
function AttendanceRecordsTable({
|
||||||
records,
|
records,
|
||||||
}: {
|
}: {
|
||||||
@@ -128,6 +169,7 @@ function AttendanceRecordsTable({
|
|||||||
<thead className="border-b bg-muted/30">
|
<thead className="border-b bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-3 text-left font-medium">{t("colDate")}</th>
|
<th className="p-3 text-left font-medium">{t("colDate")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colClass")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colRemark")}</th>
|
<th className="p-3 text-left font-medium">{t("colRemark")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -138,6 +180,9 @@ function AttendanceRecordsTable({
|
|||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||||
{formatDate(record.date)}
|
{formatDate(record.date)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3 text-xs text-muted-foreground">
|
||||||
|
{record.className ?? "--"}
|
||||||
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<AttendanceStatusBadge status={record.status} />
|
<AttendanceStatusBadge status={record.status} />
|
||||||
</td>
|
</td>
|
||||||
@@ -152,15 +197,13 @@ function AttendanceRecordsTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 考勤状态徽章(按状态色阶展示)。
|
|
||||||
*/
|
|
||||||
function AttendanceStatusBadge({
|
function AttendanceStatusBadge({
|
||||||
status,
|
status,
|
||||||
}: {
|
}: {
|
||||||
status: string;
|
status: string;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const label = formatAttendanceStatus(status);
|
const t = useTranslations("studentDomain.attendance");
|
||||||
|
const label = formatAttendanceStatus(status, t);
|
||||||
const cls = attendanceStatusToBadgeClass(status);
|
const cls = attendanceStatusToBadgeClass(status);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -171,21 +214,28 @@ function AttendanceStatusBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 考勤状态中文标签映射。 */
|
function formatAttendanceStatus(
|
||||||
const ATTENDANCE_STATUS_LABEL: Record<string, string> = {
|
status: string,
|
||||||
present: "出勤",
|
t: ReturnType<typeof useTranslations>,
|
||||||
late: "迟到",
|
): string {
|
||||||
early_leave: "早退",
|
switch (status) {
|
||||||
leave: "请假",
|
case "present":
|
||||||
absent: "缺勤",
|
return t("status.present");
|
||||||
};
|
case "late":
|
||||||
|
return t("status.late");
|
||||||
/** 将考勤状态枚举值映射为中文标签。未知状态回退为原始值。 */
|
case "early_leave":
|
||||||
function formatAttendanceStatus(status: string): string {
|
return t("status.early_leave");
|
||||||
return ATTENDANCE_STATUS_LABEL[status] ?? status;
|
case "leave":
|
||||||
|
return t("status.leave");
|
||||||
|
case "absent":
|
||||||
|
return t("status.absent");
|
||||||
|
case "school_activity":
|
||||||
|
return t("status.school_activity");
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据考勤状态返回 Tailwind 徽章类名。 */
|
|
||||||
function attendanceStatusToBadgeClass(status: string): string {
|
function attendanceStatusToBadgeClass(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "present":
|
case "present":
|
||||||
@@ -198,18 +248,19 @@ function attendanceStatusToBadgeClass(status: string): string {
|
|||||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||||
case "absent":
|
case "absent":
|
||||||
return "bg-destructive/10 text-destructive";
|
return "bg-destructive/10 text-destructive";
|
||||||
|
case "school_activity":
|
||||||
|
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
|
||||||
default:
|
default:
|
||||||
return "bg-muted text-muted-foreground";
|
return "bg-muted text-muted-foreground";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化 0-1 的出勤率为百分比字符串。 */
|
function formatRate(rate: number | undefined): string {
|
||||||
function formatRate(rate: number): string {
|
if (rate === undefined || !Number.isFinite(rate) || rate < 0 || rate > 1)
|
||||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
return "--";
|
||||||
return `${(rate * 100).toFixed(1)}%`;
|
return `${(rate * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化 ISO 日期字符串为本地化展示。 */
|
|
||||||
function formatDate(isoDate: string): string {
|
function formatDate(isoDate: string): string {
|
||||||
if (!isoDate) return "--";
|
if (!isoDate) return "--";
|
||||||
const d = new Date(isoDate);
|
const d = new Date(isoDate);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// @contract-pending:studentCoursePlanDetail schema 未实现,全 MSW 兜底
|
|
||||||
/**
|
/**
|
||||||
* 学生课程计划详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
* 学生课程计划详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
@@ -10,33 +9,42 @@
|
|||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):
|
* 三态规范(§11.3 DoD):
|
||||||
* - loading:DetailPageSkeleton
|
* - loading:DetailPageSkeleton
|
||||||
* - error:errorNode 局部降级
|
* - error:errorNode 局部降级 + notify.error
|
||||||
* - notFound:data 为 null 时显示空态节点
|
* - notFound:data 为 null 时显示空态节点
|
||||||
*
|
*
|
||||||
|
* 学生视角:只读,无拖拽/编辑/删除/批量操作。
|
||||||
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { ClipboardList, BookOpen } from "lucide-react";
|
import { ClipboardList, Download } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, type ReactElement } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useStudentCoursePlanDetail,
|
useStudentCoursePlanDetail,
|
||||||
type StudentCoursePlanDetail as CoursePlanDetailData,
|
type StudentCoursePlanDetail as CoursePlanDetailData,
|
||||||
|
type StudentCoursePlanItem,
|
||||||
|
type StudentCoursePlanItemStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
DetailPageShell,
|
DetailPageShell,
|
||||||
DetailPageSkeleton,
|
DetailPageSkeleton,
|
||||||
DetailSection,
|
|
||||||
DetailField,
|
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
/**
|
/** 详情客户端主体。需由 server page 包裹在 <Suspense> 中。 */
|
||||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
export function StudentCoursePlanDetailClient(): ReactElement {
|
||||||
*/
|
|
||||||
export function StudentCoursePlanDetailClient(): React.ReactElement {
|
|
||||||
const t = useTranslations("studentDomain.coursePlans.detail");
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -86,41 +94,425 @@ export function StudentCoursePlanDetailClient(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 详情主体(Header Card + 周计划表格)。 */
|
||||||
* 详情主体(基本信息 + 关联教材列表)。
|
|
||||||
*/
|
|
||||||
function CoursePlanDetailBody({
|
function CoursePlanDetailBody({
|
||||||
plan,
|
plan,
|
||||||
}: {
|
}: {
|
||||||
plan: CoursePlanDetailData;
|
plan: CoursePlanDetailData;
|
||||||
}): React.ReactElement {
|
}): ReactElement {
|
||||||
const t = useTranslations("studentDomain.coursePlans.detail");
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DetailSection title={t("sectionBasic")}>
|
|
||||||
<DetailField label={t("fieldTitle")} value={plan.title} />
|
|
||||||
<DetailField label={t("fieldSubject")} value={plan.subject} />
|
|
||||||
<DetailField label={t("fieldGrade")} value={plan.grade} />
|
|
||||||
<DetailField label={t("fieldDescription")} value={plan.description} />
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection title={t("sectionTextbooks")}>
|
const handleExport = (): void => {
|
||||||
{plan.textbooks.length === 0 ? (
|
try {
|
||||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
const csv = exportPlanAsCsv(plan, t);
|
||||||
) : (
|
downloadCsv(
|
||||||
<ul className="space-y-2">
|
csv,
|
||||||
{plan.textbooks.map((title, idx) => (
|
`${plan.subject}-${plan.className ?? "no-class"}-course-plan.csv`,
|
||||||
<li
|
);
|
||||||
key={`${title}-${idx}`}
|
notify.success(t("toastExported"));
|
||||||
className="flex items-center gap-2 rounded-md border bg-card p-3 text-sm"
|
} catch {
|
||||||
>
|
notify.error(t("toastExportFailed"));
|
||||||
<BookOpen className="size-4 text-muted-foreground" />
|
}
|
||||||
<span className="font-medium">{title}</span>
|
};
|
||||||
</li>
|
|
||||||
))}
|
return (
|
||||||
</ul>
|
<div className="flex flex-col gap-6">
|
||||||
)}
|
<SectionErrorBoundary title={t("sectionBasic")}>
|
||||||
</DetailSection>
|
<HeaderCard plan={plan} onExport={handleExport} />
|
||||||
</>
|
</SectionErrorBoundary>
|
||||||
|
|
||||||
|
<SectionErrorBoundary title={t("sectionWeekPlans")}>
|
||||||
|
<WeeklyPlansCard plan={plan} />
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header Card:标题、徽章、教师、日期、进度、大纲、目标。 */
|
||||||
|
function HeaderCard({
|
||||||
|
plan,
|
||||||
|
onExport,
|
||||||
|
}: {
|
||||||
|
plan: CoursePlanDetailData;
|
||||||
|
onExport: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{plan.className ?? t("badgeNoClass")}</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{plan.subject ?? t("badgeUnknownSubject")}
|
||||||
|
</Badge>
|
||||||
|
{plan.status ? (
|
||||||
|
<Badge>{formatPlanStatus(plan.status, t)}</Badge>
|
||||||
|
) : null}
|
||||||
|
{plan.semester ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("fieldSemester", { semester: plan.semester })}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">
|
||||||
|
{plan.subject ?? t("badgeUnknownSubject")} —{" "}
|
||||||
|
{plan.className ?? t("badgeNoClass")}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{plan.teacherName
|
||||||
|
? `${t("fieldTeacher")}: ${plan.teacherName}`
|
||||||
|
: t("fieldUnassigned")}
|
||||||
|
</span>
|
||||||
|
{plan.createdAt ? (
|
||||||
|
<span>
|
||||||
|
· {t("fieldCreatedAt", { date: formatDate(plan.createdAt) })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{plan.startDate ? (
|
||||||
|
<span>
|
||||||
|
· {t("fieldStartDate", { date: formatDate(plan.startDate) })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{plan.endDate ? (
|
||||||
|
<span>
|
||||||
|
· {t("fieldEndDate", { date: formatDate(plan.endDate) })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={onExport}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!plan.items || plan.items.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 size-4" aria-hidden="true" />
|
||||||
|
{t("exportCsv")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<CoursePlanProgress plan={plan} />
|
||||||
|
<SyllabusObjectives plan={plan} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进度条:完成学时 + 完成项数。纯 Tailwind div 实现,无新依赖。 */
|
||||||
|
function CoursePlanProgress({
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
plan: CoursePlanDetailData;
|
||||||
|
}): ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
|
|
||||||
|
const completedHours = plan.completedHours ?? 0;
|
||||||
|
const totalHours = plan.totalHours ?? 0;
|
||||||
|
const hoursPercent =
|
||||||
|
totalHours > 0
|
||||||
|
? Math.min(100, Math.round((completedHours / totalHours) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const completedItems = plan.completedItems ?? 0;
|
||||||
|
const totalItems = plan.totalItems ?? 0;
|
||||||
|
const itemsPercent =
|
||||||
|
totalItems > 0
|
||||||
|
? Math.min(100, Math.round((completedItems / totalItems) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{t("progressLabel")}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("progressHours", {
|
||||||
|
completed: completedHours,
|
||||||
|
total: totalHours,
|
||||||
|
percent: hoursPercent,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={hoursPercent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${hoursPercent}%` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{t("sectionWeekPlans")}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("progressItems", {
|
||||||
|
completed: completedItems,
|
||||||
|
total: totalItems,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={itemsPercent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-emerald-500/60 transition-all"
|
||||||
|
style={{ width: `${itemsPercent}%` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 教学大纲 + 教学目标文本区。 */
|
||||||
|
function SyllabusObjectives({
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
plan: CoursePlanDetailData;
|
||||||
|
}): ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-semibold">{t("sectionSyllabus")}</h4>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||||
|
{plan.syllabus && plan.syllabus.trim().length > 0
|
||||||
|
? plan.syllabus
|
||||||
|
: t("emptyText")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-semibold">{t("sectionObjectives")}</h4>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||||
|
{plan.objectives && plan.objectives.trim().length > 0
|
||||||
|
? plan.objectives
|
||||||
|
: t("emptyText")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周计划表格卡片。学生视角只读。 */
|
||||||
|
function WeeklyPlansCard({
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
plan: CoursePlanDetailData;
|
||||||
|
}): ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
|
const items = plan.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("sectionWeekPlans")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("emptyWeekPlans")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="w-16 p-3 text-left font-medium">
|
||||||
|
{t("colWeek")}
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colTopic")}</th>
|
||||||
|
<th className="w-20 p-3 text-left font-medium">
|
||||||
|
{t("colHours")}
|
||||||
|
</th>
|
||||||
|
<th className="w-40 p-3 text-left font-medium">
|
||||||
|
{t("colChapter")}
|
||||||
|
</th>
|
||||||
|
<th className="w-28 p-3 text-left font-medium">
|
||||||
|
{t("colStatus")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{items.map((item) => (
|
||||||
|
<WeeklyPlanRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周计划表格行:圆形周次徽章 + 主题 + 时长 + 章节 + 状态 Badge。 */
|
||||||
|
function WeeklyPlanRow({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: StudentCoursePlanItem;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-muted/30">
|
||||||
|
<td className="p-3">
|
||||||
|
<span className="inline-flex size-7 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
|
{item.week}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-medium">{item.topic}</td>
|
||||||
|
<td className="p-3 text-muted-foreground">{item.hours}</td>
|
||||||
|
<td className="p-3 text-muted-foreground">{item.chapter ?? "—"}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周计划项状态徽章。 */
|
||||||
|
function StatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: StudentCoursePlanItemStatus;
|
||||||
|
}): ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.coursePlans.detail");
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={statusToBadgeClass(status)}>
|
||||||
|
{formatItemStatus(status, t)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 工具函数 =====
|
||||||
|
|
||||||
|
/** 格式化 ISO 日期为本地化短日期。 */
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "--";
|
||||||
|
return d.toLocaleDateString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化课程计划状态(后端返回的状态字符串)。 */
|
||||||
|
function formatPlanStatus(
|
||||||
|
status: string,
|
||||||
|
t: ReturnType<typeof useTranslations>,
|
||||||
|
): string {
|
||||||
|
switch (status) {
|
||||||
|
case "planned":
|
||||||
|
case "PLANNED":
|
||||||
|
case "DRAFT":
|
||||||
|
return t("statusPlanned");
|
||||||
|
case "in_progress":
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return t("statusInProgress");
|
||||||
|
case "completed":
|
||||||
|
case "COMPLETED":
|
||||||
|
return t("statusCompleted");
|
||||||
|
case "skipped":
|
||||||
|
case "ARCHIVED":
|
||||||
|
return t("statusSkipped");
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化周计划项状态。 */
|
||||||
|
function formatItemStatus(
|
||||||
|
status: StudentCoursePlanItemStatus,
|
||||||
|
t: ReturnType<typeof useTranslations>,
|
||||||
|
): string {
|
||||||
|
switch (status) {
|
||||||
|
case "planned":
|
||||||
|
return t("statusPlanned");
|
||||||
|
case "in_progress":
|
||||||
|
return t("statusInProgress");
|
||||||
|
case "completed":
|
||||||
|
return t("statusCompleted");
|
||||||
|
case "skipped":
|
||||||
|
return t("statusSkipped");
|
||||||
|
default:
|
||||||
|
return t("statusUnknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周计划项状态 → Tailwind 徽章类名映射。 */
|
||||||
|
function statusToBadgeClass(status: StudentCoursePlanItemStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
case "in_progress":
|
||||||
|
return "border-transparent bg-primary/10 text-primary";
|
||||||
|
case "planned":
|
||||||
|
return "border-transparent bg-muted text-muted-foreground";
|
||||||
|
case "skipped":
|
||||||
|
return "border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||||
|
default:
|
||||||
|
return "border-transparent bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出课程计划为 CSV 字符串。 */
|
||||||
|
function exportPlanAsCsv(
|
||||||
|
plan: CoursePlanDetailData,
|
||||||
|
t: ReturnType<typeof useTranslations>,
|
||||||
|
): string {
|
||||||
|
const headers = [
|
||||||
|
t("colWeek"),
|
||||||
|
t("colTopic"),
|
||||||
|
t("colHours"),
|
||||||
|
t("colChapter"),
|
||||||
|
t("colStatus"),
|
||||||
|
];
|
||||||
|
const rows = (plan.items ?? []).map((item) => [
|
||||||
|
String(item.week),
|
||||||
|
item.topic,
|
||||||
|
String(item.hours),
|
||||||
|
item.chapter ?? "",
|
||||||
|
formatItemStatus(item.status, t),
|
||||||
|
]);
|
||||||
|
const allRows = [headers, ...rows];
|
||||||
|
return allRows
|
||||||
|
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(","))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV 单元格转义。 */
|
||||||
|
function escapeCsvCell(value: string): string {
|
||||||
|
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发浏览器下载 CSV 文件。 */
|
||||||
|
function downloadCsv(csv: string, filename: string): void {
|
||||||
|
const blob = new Blob([`\uFEFF${csv}`], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,29 +8,59 @@
|
|||||||
* - studentCoursePlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentCoursePlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
*
|
*
|
||||||
|
* URL 状态:?status=(客户端按状态过滤)
|
||||||
|
*
|
||||||
|
* 视图结构(Card 网格布局):
|
||||||
|
* - 状态筛选 Select(全部/进行中/已完成/计划中/已暂停)
|
||||||
|
* - 课程计划 Card 网格(标题 + 班级/教师/学期/进度条 + 状态徽章)
|
||||||
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { ClipboardList } from "lucide-react";
|
import { ClipboardList } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useStudentCoursePlans, type StudentCoursePlan } from "@/lib/api";
|
import { useStudentCoursePlans, type StudentCoursePlan } from "@/lib/api";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import {
|
||||||
|
FilterBar,
|
||||||
|
FilterSearchInput,
|
||||||
|
} from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/** 状态筛选选项(value 与后端 status 字符串对齐) */
|
||||||
|
const STATUS_FILTER_OPTIONS: readonly { value: string; labelKey: string }[] = [
|
||||||
|
{ value: "", labelKey: "statusAll" },
|
||||||
|
{ value: "active", labelKey: "statusActive" },
|
||||||
|
{ value: "completed", labelKey: "statusCompleted" },
|
||||||
|
{ value: "planning", labelKey: "statusPlanning" },
|
||||||
|
{ value: "paused", labelKey: "statusPaused" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
*/
|
*/
|
||||||
export function StudentCoursePlansListClient(): React.ReactElement {
|
export function StudentCoursePlansListClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.coursePlans.list");
|
const t = useTranslations("studentDomain.coursePlans.list");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const statusFilter = searchParams.get("status") ?? "";
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useStudentCoursePlans();
|
const { data, loading, error } = useStudentCoursePlans();
|
||||||
@@ -43,6 +73,37 @@ export function StudentCoursePlansListClient(): React.ReactElement {
|
|||||||
|
|
||||||
const items = data ?? [];
|
const items = data ?? [];
|
||||||
|
|
||||||
|
// 客户端按状态/名称过滤
|
||||||
|
const filteredItems = useMemo<StudentCoursePlan[]>(() => {
|
||||||
|
return items.filter((plan) => {
|
||||||
|
if (statusFilter && plan.status !== statusFilter) return false;
|
||||||
|
if (q) {
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
if (
|
||||||
|
!plan.title.toLowerCase().includes(lower) &&
|
||||||
|
!plan.subject.toLowerCase().includes(lower) &&
|
||||||
|
!(plan.className ?? "").toLowerCase().includes(lower) &&
|
||||||
|
!(plan.teacherName ?? "").toLowerCase().includes(lower)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [items, statusFilter, q]);
|
||||||
|
|
||||||
|
const updateQuery = (key: string, value: string): void => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/shell/student/course-plans?${params.toString()}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -68,69 +129,236 @@ export function StudentCoursePlansListClient(): React.ReactElement {
|
|||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<ClipboardList className="size-6" />}
|
icon={<ClipboardList className="size-6" />}
|
||||||
loading={loading}
|
filters={
|
||||||
|
<FilterBar variant="wrap">
|
||||||
|
<FilterSearchInput
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
value={q}
|
||||||
|
onChange={(v) => updateQuery("q", v)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => updateQuery("status", v)}
|
||||||
|
options={STATUS_FILTER_OPTIONS.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: t(opt.labelKey),
|
||||||
|
}))}
|
||||||
|
placeholder={t("filterByStatus")}
|
||||||
|
aria-label={t("filterByStatus")}
|
||||||
|
className="md:w-40"
|
||||||
|
/>
|
||||||
|
</FilterBar>
|
||||||
|
}
|
||||||
|
loading={loading && items.length === 0}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
empty={items.length === 0 && !loading}
|
empty={filteredItems.length === 0 && !loading}
|
||||||
emptyNode={emptyNode}
|
emptyNode={emptyNode}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
<span>{t("total", { count: items.length })}</span>
|
<span className="text-xs">{t("mswNotice")}</span>
|
||||||
|
<span>{t("total", { count: filteredItems.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StudentCoursePlansTable items={items} />
|
<CoursePlanCardGrid items={filteredItems} />
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生课程计划列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
* 课程计划 Card 网格(小屏 1 列,桌面 2-3 列)。
|
||||||
*/
|
*/
|
||||||
function StudentCoursePlansTable({
|
function CoursePlanCardGrid({
|
||||||
items,
|
items,
|
||||||
}: {
|
}: {
|
||||||
items: StudentCoursePlan[];
|
items: StudentCoursePlan[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.coursePlans.list");
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<table className="w-full text-sm">
|
{items.map((plan) => (
|
||||||
<thead className="border-b bg-muted/30">
|
<CoursePlanCard key={plan.id} plan={plan} />
|
||||||
<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>
|
</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 局部降级
|
* - error:errorNode 局部降级
|
||||||
* - notFound:data 为 null 时显示空态节点
|
* - notFound:data 为 null 时显示空态节点
|
||||||
*
|
*
|
||||||
|
* 布局:3 卡片网格(基本信息 / 教师信息 / 本班课表),小屏单列堆叠。
|
||||||
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { GraduationCap } from "lucide-react";
|
import { GraduationCap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useStudentCourseDetail,
|
useStudentCourseDetail,
|
||||||
type StudentCourseDetail as StudentCourseDetailData,
|
type StudentCourseDetail as StudentCourseDetailData,
|
||||||
type StudentCourseScheduleItem,
|
type StudentCourseScheduleItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +39,46 @@ import {
|
|||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
|
/** Weekday i18n key 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> 中。
|
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
*/
|
*/
|
||||||
@@ -89,7 +133,8 @@ export function StudentCourseDetailClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情主体(基本信息 + 教师信息 + 本班课表)。
|
* 详情主体(3 卡片网格:基本信息 + 教师信息 + 本班课表)。
|
||||||
|
* 小屏单列堆叠,lg+ 三列网格。
|
||||||
*/
|
*/
|
||||||
function CourseDetailBody({
|
function CourseDetailBody({
|
||||||
course,
|
course,
|
||||||
@@ -98,16 +143,21 @@ function CourseDetailBody({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.courses.detail");
|
const t = useTranslations("studentDomain.courses.detail");
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<DetailSection title={t("sectionBasic")}>
|
<DetailSection title={t("sectionBasic")} className="lg:col-span-1">
|
||||||
<DetailField label={t("fieldName")} value={course.name} />
|
<div className="grid grid-cols-1 gap-x-4 gap-y-1">
|
||||||
<DetailField label={t("fieldGrade")} value={course.grade} />
|
<DetailField label={t("fieldName")} value={course.name} />
|
||||||
<DetailField label={t("fieldHeadTeacher")} value={course.headTeacher} />
|
<DetailField label={t("fieldGrade")} value={course.grade} />
|
||||||
<DetailField label={t("fieldClassroom")} value={course.classroom} />
|
<DetailField
|
||||||
<DetailField label={t("fieldSchool")} value={course.school} />
|
label={t("fieldHeadTeacher")}
|
||||||
|
value={course.headTeacher}
|
||||||
|
/>
|
||||||
|
<DetailField label={t("fieldClassroom")} value={course.classroom} />
|
||||||
|
<DetailField label={t("fieldSchool")} value={course.school} />
|
||||||
|
</div>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title={t("sectionTeachers")}>
|
<DetailSection title={t("sectionTeachers")} className="lg:col-span-1">
|
||||||
{course.teachers.length === 0 ? (
|
{course.teachers.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -138,20 +188,28 @@ function CourseDetailBody({
|
|||||||
|
|
||||||
<DetailSection
|
<DetailSection
|
||||||
title={t("sectionSchedule")}
|
title={t("sectionSchedule")}
|
||||||
|
className="lg:col-span-1"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm">
|
<div className="flex flex-wrap gap-2">
|
||||||
{t("viewFullSchedule")}
|
<Button asChild variant="outline" size="sm">
|
||||||
</Button>
|
<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} />
|
<ScheduleTable schedule={course.schedule} />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本班课表表格。
|
* 本班课表表格(按星期/节次排序,含 weekday 徽章与全部字段)。
|
||||||
*/
|
*/
|
||||||
function ScheduleTable({
|
function ScheduleTable({
|
||||||
schedule,
|
schedule,
|
||||||
@@ -159,28 +217,45 @@ function ScheduleTable({
|
|||||||
schedule: StudentCourseScheduleItem[];
|
schedule: StudentCourseScheduleItem[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.courses.detail");
|
const t = useTranslations("studentDomain.courses.detail");
|
||||||
if (schedule.length === 0) {
|
const sorted = useMemo(() => sortSchedule(schedule), [schedule]);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("notFound")}</p>;
|
return <p className="text-sm text-muted-foreground">{t("notFound")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b bg-muted/30">
|
<thead className="border-b bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-3 text-left font-medium">{t("fieldName")}</th>
|
<th className="p-3 text-left font-medium">{t("colWeekday")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colPeriod")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colTeacherName")}</th>
|
<th className="p-3 text-left font-medium">{t("colTeacherName")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("fieldClassroom")}</th>
|
<th className="p-3 text-left font-medium">{t("fieldClassroom")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colTime")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colCourse")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{schedule.map((item, idx) => (
|
{sorted.map((item, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${item.weekday}-${item.period}-${idx}`}
|
key={`${item.weekday}-${item.period}-${idx}`}
|
||||||
className="hover:bg-muted/30"
|
className="hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={weekdayBadgeVariant(item.weekday)}>
|
||||||
|
{t(weekdayI18nKey(item.weekday))}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-muted-foreground">{item.period}</td>
|
||||||
<td className="p-3 font-medium">{item.subject}</td>
|
<td className="p-3 font-medium">{item.subject}</td>
|
||||||
<td className="p-3 text-muted-foreground">{item.teacher}</td>
|
<td className="p-3 text-muted-foreground">{item.teacher}</td>
|
||||||
<td className="p-3 text-muted-foreground">{item.classroom}</td>
|
<td className="p-3 text-muted-foreground">{item.classroom}</td>
|
||||||
|
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||||
|
{item.time}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-muted-foreground">{item.course}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,28 +6,65 @@
|
|||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - studentCourses(q) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentCourses(q) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - joinClassByInvitationCode(input) ❌ schema 无此 mutation → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
*
|
*
|
||||||
* URL 状态:?q=
|
* URL 状态:?q=
|
||||||
*
|
*
|
||||||
|
* 视图模式:card(默认,卡片网格)/ table(表格)
|
||||||
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { GraduationCap } from "lucide-react";
|
import {
|
||||||
|
GraduationCap,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useStudentCourses, type StudentCourse } from "@/lib/api";
|
import {
|
||||||
|
useJoinClassByInvitationCode,
|
||||||
|
useStudentCourses,
|
||||||
|
type StudentCourse,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
|
type ViewMode = "card" | "table";
|
||||||
|
|
||||||
|
/** 邀请码校验:6 位数字 */
|
||||||
|
const INVITE_CODE_PATTERN = /^\d{6}$/;
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
@@ -40,6 +77,8 @@ export function StudentCoursesListClient(): React.ReactElement {
|
|||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
||||||
|
const [joinOpen, setJoinOpen] = useState(false);
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useStudentCourses({
|
const { data, loading, error } = useStudentCourses({
|
||||||
@@ -60,6 +99,12 @@ export function StudentCoursesListClient(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleJoinSuccess = (): void => {
|
||||||
|
setJoinOpen(false);
|
||||||
|
startTransition(() => {
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -86,11 +131,56 @@ export function StudentCoursesListClient(): React.ReactElement {
|
|||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<GraduationCap className="size-6" />}
|
icon={<GraduationCap className="size-6" />}
|
||||||
filters={
|
filters={
|
||||||
<FilterSearchInput
|
<>
|
||||||
placeholder={t("searchPlaceholder")}
|
<FilterSearchInput
|
||||||
value={q}
|
placeholder={t("searchPlaceholder")}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
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}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
@@ -103,11 +193,118 @@ export function StudentCoursesListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StudentCoursesTable items={items} />
|
{viewMode === "card" ? (
|
||||||
|
<StudentCoursesCardGrid items={items} />
|
||||||
|
) : (
|
||||||
|
<StudentCoursesTable items={items} />
|
||||||
|
)}
|
||||||
|
<JoinClassDialog
|
||||||
|
open={joinOpen}
|
||||||
|
onOpenChange={setJoinOpen}
|
||||||
|
onSuccess={handleJoinSuccess}
|
||||||
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 学生课程卡片网格(响应式:小屏 1 列,sm 2 列,lg 3 列)。
|
||||||
|
*/
|
||||||
|
function StudentCoursesCardGrid({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: StudentCourse[];
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((course) => (
|
||||||
|
<StudentCourseCard key={course.id} course={course} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个课程卡片。
|
||||||
|
*/
|
||||||
|
function StudentCourseCard({
|
||||||
|
course,
|
||||||
|
}: {
|
||||||
|
course: StudentCourse;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.courses.list");
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<Link
|
||||||
|
href={`/shell/student/courses/${course.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{course.name}
|
||||||
|
</Link>
|
||||||
|
</CardTitle>
|
||||||
|
{typeof course.isActive === "boolean" ? (
|
||||||
|
<Badge variant={course.isActive ? "default" : "secondary"}>
|
||||||
|
{course.isActive ? t("statusActive") : t("statusInactive")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("cardTeacher")}
|
||||||
|
</span>
|
||||||
|
<span>{course.teacher}</span>
|
||||||
|
{course.teacherEmail ? (
|
||||||
|
<a
|
||||||
|
href={`mailto:${course.teacherEmail}`}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
aria-label={t("sendEmail")}
|
||||||
|
>
|
||||||
|
<Mail className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{t("cardSchool")}</span>
|
||||||
|
<span>{course.school}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("cardHeadTeacher")}
|
||||||
|
</span>
|
||||||
|
<span>{course.headTeacher}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-muted-foreground">
|
||||||
|
{course.grade ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("cardGrade")}
|
||||||
|
</span>
|
||||||
|
<span>{course.grade}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{course.room ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MapPin className="size-3.5" />
|
||||||
|
<span>{course.room}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto pt-3">
|
||||||
|
<Link
|
||||||
|
href={`/shell/student/courses/${course.id}`}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{t("viewDetail")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 学生课程列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
* 学生课程列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||||
*/
|
*/
|
||||||
@@ -126,6 +323,8 @@ function StudentCoursesTable({
|
|||||||
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
|
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colSchool")}</th>
|
<th className="p-3 text-left font-medium">{t("colSchool")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colHeadTeacher")}</th>
|
<th className="p-3 text-left font-medium">{t("colHeadTeacher")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colRoom")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
|
||||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -140,11 +339,30 @@ function StudentCoursesTable({
|
|||||||
{course.name}
|
{course.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">{course.teacher}</td>
|
<td className="p-3 text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{course.teacher}</span>
|
||||||
|
{course.teacherEmail ? (
|
||||||
|
<a
|
||||||
|
href={`mailto:${course.teacherEmail}`}
|
||||||
|
className="inline-flex items-center text-primary hover:underline"
|
||||||
|
aria-label={t("sendEmail")}
|
||||||
|
>
|
||||||
|
<Mail className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">{course.school}</td>
|
<td className="p-3 text-muted-foreground">{course.school}</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<td className="p-3 text-muted-foreground">
|
||||||
{course.headTeacher}
|
{course.headTeacher}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3 text-muted-foreground">
|
||||||
|
{course.room ?? "-"}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-muted-foreground">
|
||||||
|
{course.grade ?? "-"}
|
||||||
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<Link
|
<Link
|
||||||
href={`/shell/student/courses/${course.id}`}
|
href={`/shell/student/courses/${course.id}`}
|
||||||
@@ -160,3 +378,111 @@ function StudentCoursesTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 加入班级对话框(邀请码)。
|
||||||
|
* @contract-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";
|
"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)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* Data contract:
|
||||||
* - electiveCourses(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentElectiveDetail(id) -> schema missing -> MSW fallback (@contract-pending)
|
||||||
* - 详情复用列表 hook,按 id 客户端过滤(无独立详情 hook)
|
* - Contract ticket: docs/architecture/issues/contracts/core-edu_contract.md#student-elective
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-elective
|
|
||||||
*
|
*
|
||||||
* 隐私保护:学生视角不展示选课名单(仅展示课程基本信息)。
|
* Three-state spec (§11.3 DoD):
|
||||||
|
* - loading: DetailPageSkeleton
|
||||||
|
* - error: errorNode local fallback + notify.error
|
||||||
|
* - empty: notFound (no matching course)
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):
|
* Privacy: student view does not show enrollment list (only course basic info).
|
||||||
* - loading:DetailPageSkeleton
|
|
||||||
* - error:errorNode 局部降级
|
|
||||||
* - empty:notFound(无匹配课程)
|
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
|
* Related: ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { ArrowLeft, BookOpen } from "lucide-react";
|
import { ArrowLeft, BookOpen } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useElectiveCourses } from "@/lib/api";
|
import { useStudentElectiveDetail, type ElectiveCourse } from "@/lib/api";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
DetailPageShell,
|
DetailPageShell,
|
||||||
@@ -31,12 +33,53 @@ import {
|
|||||||
DetailSection,
|
DetailSection,
|
||||||
DetailField,
|
DetailField,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
/** 默认学期(与列表页一致,详情复用列表 hook) */
|
/** Status badge variant mapping (@contract-pending, aligned with i18n status* keys) */
|
||||||
const DEFAULT_TERM_ID = "2026-spring";
|
function statusBadgeVariant(
|
||||||
|
status: ElectiveCourse["status"],
|
||||||
|
): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
switch (status) {
|
||||||
|
case "open":
|
||||||
|
return "default";
|
||||||
|
case "draft":
|
||||||
|
return "secondary";
|
||||||
|
case "closed":
|
||||||
|
return "outline";
|
||||||
|
case "cancelled":
|
||||||
|
return "destructive";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status i18n key mapping */
|
||||||
|
function statusI18nKey(status: ElectiveCourse["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "open":
|
||||||
|
return "statusOpen";
|
||||||
|
case "draft":
|
||||||
|
return "statusDraft";
|
||||||
|
case "closed":
|
||||||
|
return "statusClosed";
|
||||||
|
case "cancelled":
|
||||||
|
return "statusCancelled";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selection mode i18n key mapping */
|
||||||
|
function selectionModeI18nKey(mode: ElectiveCourse["selectionMode"]): string {
|
||||||
|
return mode === "fcfs" ? "selectionModeFcfs" : "selectionModeLottery";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format ISO timestamp to locale string (client-only render, no SSR hydration risk) */
|
||||||
|
function formatIso(iso: string): string {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return iso;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选课详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* Elective detail client body. Must be wrapped in <Suspense> by server page.
|
||||||
*/
|
*/
|
||||||
export function StudentElectiveDetailClient(): React.ReactElement {
|
export function StudentElectiveDetailClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.elective.detail");
|
const t = useTranslations("studentDomain.elective.detail");
|
||||||
@@ -44,10 +87,14 @@ export function StudentElectiveDetailClient(): React.ReactElement {
|
|||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const courseId = params?.id ?? "";
|
const courseId = params?.id ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底(详情复用列表 hook,客户端过滤)
|
// @contract-pending: MSW fallback
|
||||||
const { data, loading, error } = useElectiveCourses(DEFAULT_TERM_ID);
|
const { data: course, loading, error } = useStudentElectiveDetail(courseId);
|
||||||
|
|
||||||
const course = data?.find((c) => c.id === courseId);
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||||
|
}
|
||||||
|
}, [error, tCommon]);
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
@@ -59,36 +106,98 @@ export function StudentElectiveDetailClient(): React.ReactElement {
|
|||||||
|
|
||||||
const emptyNode =
|
const emptyNode =
|
||||||
!loading && !error && !course ? (
|
!loading && !error && !course ? (
|
||||||
<EmptyState icon={BookOpen} title={t("notFound")} />
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("notFound")}
|
||||||
|
action={{
|
||||||
|
label: t("backToList"),
|
||||||
|
href: "/shell/student/elective",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
const statusBadge = course ? (
|
||||||
|
<Badge variant={statusBadgeVariant(course.status)}>
|
||||||
|
{t(statusI18nKey(course.status))}
|
||||||
|
</Badge>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPageShell
|
<DetailPageShell
|
||||||
title={course?.name ?? t("title")}
|
title={course?.name ?? t("title")}
|
||||||
description={t("sectionBasic")}
|
description={t("sectionBasic")}
|
||||||
icon={<BookOpen className="size-6" />}
|
icon={<BookOpen className="size-6" />}
|
||||||
|
actions={statusBadge}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<DetailPageSkeleton />}
|
loadingNode={<DetailPageSkeleton />}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
emptyNode={emptyNode}
|
emptyNode={emptyNode}
|
||||||
>
|
>
|
||||||
<Link
|
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||||
href="/shell/student/elective"
|
<Link href="/shell/student/elective">
|
||||||
className="inline-flex h-9 items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
<ArrowLeft className="size-4" />
|
||||||
>
|
{t("backToList")}
|
||||||
<ArrowLeft className="size-4" />
|
</Link>
|
||||||
{t("backToList")}
|
</Button>
|
||||||
</Link>
|
{course ? <ElectiveDetailBody course={course} /> : null}
|
||||||
{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}
|
|
||||||
</DetailPageShell>
|
</DetailPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail body (basic info + description + schedule).
|
||||||
|
*/
|
||||||
|
function ElectiveDetailBody({
|
||||||
|
course,
|
||||||
|
}: {
|
||||||
|
course: ElectiveCourse;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.elective.detail");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title={t("sectionBasic")}>
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<DetailField label={t("fieldName")} value={course.name} />
|
||||||
|
<DetailField label={t("fieldTeacher")} value={course.teacher} />
|
||||||
|
<DetailField label={t("fieldCategory")} value={course.category} />
|
||||||
|
<DetailField label={t("fieldSubject")} value={course.subject} />
|
||||||
|
<DetailField label={t("fieldGrade")} value={course.grade} />
|
||||||
|
<DetailField label={t("fieldClassroom")} value={course.classroom} />
|
||||||
|
<DetailField label={t("fieldCredits")} value={course.credits} />
|
||||||
|
<DetailField label={t("fieldCapacity")} value={course.capacity} />
|
||||||
|
<DetailField label={t("fieldEnrolled")} value={course.enrolled} />
|
||||||
|
<DetailField
|
||||||
|
label={t("fieldSelectionMode")}
|
||||||
|
value={t(selectionModeI18nKey(course.selectionMode))}
|
||||||
|
/>
|
||||||
|
<DetailField label={t("fieldStartDate")} value={course.startDate} />
|
||||||
|
<DetailField label={t("fieldEndDate")} value={course.endDate} />
|
||||||
|
<DetailField
|
||||||
|
label={t("fieldSelectionStartAt")}
|
||||||
|
value={formatIso(course.selectionStartAt)}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("fieldSelectionEndAt")}
|
||||||
|
value={formatIso(course.selectionEndAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title={t("sectionDescription")}>
|
||||||
|
{course.description ? (
|
||||||
|
<p className="whitespace-pre-line text-sm leading-relaxed text-foreground">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">-</p>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection title={t("sectionSchedule")}>
|
||||||
|
<p className="text-sm leading-relaxed text-foreground">
|
||||||
|
{course.schedule}
|
||||||
|
</p>
|
||||||
|
</DetailSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// @contract-pending:electiveCourses schema 未实现,全 MSW 兜底
|
// @contract-pending:electiveCourses / studentSelections schema 未实现,全 MSW 兜底
|
||||||
/**
|
/**
|
||||||
* 学生选课列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
|
* 学生选课列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - electiveCourses(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - electiveCourses(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - enrollCourse mutation ❌ schema 无 Mutation 类型 → MSW 兜底
|
* - studentSelections(termId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - enrollCourse / dropCourse mutation ❌ schema 无 Mutation 类型 → MSW 兜底
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-elective
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-elective
|
||||||
*
|
*
|
||||||
* URL 状态:?q=(客户端按名称过滤,MSW 不支持服务端过滤)
|
* URL 状态:?q=&status=&mode=(客户端按名称/状态/选课模式过滤)
|
||||||
*
|
*
|
||||||
* 两分区结构:
|
* 两分区结构(Card 网格布局):
|
||||||
* - 我的选课(mock 空态,无独立 hook)
|
* - 我的选课(已选课程 Card 网格 + 退课操作 + 退课确认对话框)
|
||||||
* - 可选课程(列表表格 + 选课操作)
|
* - 可选课程(可选课程 Card 网格 + 选课操作 + 状态/选课模式徽章)
|
||||||
|
*
|
||||||
|
* 筛选器:
|
||||||
|
* - 名称搜索(FilterSearchInput)
|
||||||
|
* - 状态筛选(Select:全部/开放中/已关闭/进行中/已结束)
|
||||||
|
* - 选课模式筛选(Select:全部/先到先得/抽签)
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
@@ -22,22 +28,65 @@
|
|||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useElectiveCourses, useEnrollCourse, type Course } from "@/lib/api";
|
import {
|
||||||
|
useDropCourse,
|
||||||
|
useElectiveCourses,
|
||||||
|
useEnrollCourse,
|
||||||
|
useStudentSelections,
|
||||||
|
type Course,
|
||||||
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import {
|
||||||
|
FilterBar,
|
||||||
|
FilterSearchInput,
|
||||||
|
} from "@/shared/components/ui/filter-bar";
|
||||||
|
import { Select } from "@/shared/components/ui/select";
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
|
|
||||||
/** 默认学期(无独立 hook 查询当前学期,前端固定值) */
|
/** 默认学期(无独立 hook 查询当前学期,前端固定值) */
|
||||||
const DEFAULT_TERM_ID = "2026-spring";
|
const DEFAULT_TERM_ID = "2026-spring";
|
||||||
|
|
||||||
|
/** 课程状态枚举(与 Course.status 对齐) */
|
||||||
|
type CourseStatus = NonNullable<Course["status"]>;
|
||||||
|
|
||||||
|
/** 选课模式枚举(与 Course.selectionMode 对齐) */
|
||||||
|
type SelectionMode = NonNullable<Course["selectionMode"]>;
|
||||||
|
|
||||||
|
/** 状态筛选选项 */
|
||||||
|
const STATUS_FILTER_OPTIONS: readonly { value: string; labelKey: string }[] = [
|
||||||
|
{ value: "", labelKey: "statusAll" },
|
||||||
|
{ value: "open", labelKey: "statusOpen" },
|
||||||
|
{ value: "closed", labelKey: "statusClosed" },
|
||||||
|
{ value: "in_progress", labelKey: "statusInProgress" },
|
||||||
|
{ value: "completed", labelKey: "statusCompleted" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 选课模式筛选选项 */
|
||||||
|
const MODE_FILTER_OPTIONS: readonly { value: string; labelKey: string }[] = [
|
||||||
|
{ value: "", labelKey: "modeAll" },
|
||||||
|
{ value: "fcfs", labelKey: "modeFcfs" },
|
||||||
|
{ value: "lottery", labelKey: "modeLottery" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选课列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 选课列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
@@ -50,17 +99,46 @@ export function StudentElectiveListClient(): React.ReactElement {
|
|||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const statusFilter = searchParams.get("status") ?? "";
|
||||||
|
const modeFilter = searchParams.get("mode") ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @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 enrollMutation = useEnrollCourse();
|
||||||
|
const dropMutation = useDropCourse();
|
||||||
|
|
||||||
const items = data ?? [];
|
// 退课对话框状态
|
||||||
|
const [dropTarget, setDropTarget] = useState<Course | null>(null);
|
||||||
|
const [dropReason, setDropReason] = useState<string>("");
|
||||||
|
const [dropDialogOpen, setDropDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
// 客户端按名称过滤(MSW 不支持服务端过滤)
|
const availableItems = availableData ?? [];
|
||||||
const filtered = q
|
const selectionItems = selectionsData ?? [];
|
||||||
? items.filter((c) => c.name.toLowerCase().includes(q.toLowerCase()))
|
|
||||||
: items;
|
// 客户端按名称/状态/模式过滤可选课程
|
||||||
|
const filteredAvailable = useMemo<Course[]>(() => {
|
||||||
|
return availableItems.filter((c) => {
|
||||||
|
if (q) {
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
if (
|
||||||
|
!c.name.toLowerCase().includes(lower) &&
|
||||||
|
!(c.subjectName ?? "").toLowerCase().includes(lower) &&
|
||||||
|
!(c.description ?? "").toLowerCase().includes(lower)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (statusFilter && c.status !== statusFilter) return false;
|
||||||
|
if (modeFilter && c.selectionMode !== modeFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [availableItems, q, statusFilter, modeFilter]);
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
const updateQuery = (key: string, value: string): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -85,6 +163,32 @@ export function StudentElectiveListClient(): React.ReactElement {
|
|||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDropDialog = (course: Course): void => {
|
||||||
|
setDropTarget(course);
|
||||||
|
setDropReason("");
|
||||||
|
setDropDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropDialog = (): void => {
|
||||||
|
setDropDialogOpen(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
setDropReason("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDrop = (): void => {
|
||||||
|
if (!dropTarget) return;
|
||||||
|
const courseId = dropTarget.id;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await dropMutation.run(courseId, dropReason.trim() || undefined);
|
||||||
|
notify.success(t("dropSuccess"));
|
||||||
|
closeDropDialog();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(t("dropError", { message: String(err) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -94,14 +198,11 @@ export function StudentElectiveListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const emptyNode = (
|
const emptyAvailableNode = (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title={t("emptyAvailable")}
|
title={t("emptyAvailable")}
|
||||||
action={{
|
description={t("mswNotice")}
|
||||||
label: t("title"),
|
|
||||||
href: "/shell/student/elective",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,55 +212,272 @@ export function StudentElectiveListClient(): React.ReactElement {
|
|||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<BookOpen className="size-6" />}
|
icon={<BookOpen className="size-6" />}
|
||||||
filters={
|
filters={
|
||||||
<FilterSearchInput
|
<FilterBar variant="wrap">
|
||||||
placeholder={t("searchPlaceholder")}
|
<FilterSearchInput
|
||||||
value={q}
|
placeholder={t("searchPlaceholder")}
|
||||||
onChange={(v) => updateQuery("q", v)}
|
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} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
empty={filtered.length === 0 && !loading}
|
|
||||||
emptyNode={emptyNode}
|
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
<span className="text-xs">{t("mswNotice")}</span>
|
<span className="text-xs">{t("mswNotice")}</span>
|
||||||
<span>{t("total", { count: filtered.length })}</span>
|
<span>{t("total", { count: filteredAvailable.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* 我的选课 - mock 空态(无独立 hook 查询已选课程) */}
|
{/* 我的选课 - Card 网格 */}
|
||||||
<div className="rounded-xl border bg-card p-6">
|
<section className="space-y-3">
|
||||||
<h3 className="mb-3 text-lg font-semibold">
|
<div className="flex items-baseline justify-between">
|
||||||
{t("sectionMySelections")}
|
<h3 className="text-lg font-semibold">
|
||||||
</h3>
|
{t("sectionMySelections")}
|
||||||
<p className="text-sm text-muted-foreground">
|
</h3>
|
||||||
{t("emptyMySelections")}
|
<span className="text-xs text-muted-foreground">
|
||||||
</p>
|
{t("total", { count: selectionItems.length })}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* 可选课程 */}
|
{/* 可选课程 - Card 网格 */}
|
||||||
<div>
|
<section className="space-y-3">
|
||||||
<h3 className="mb-3 text-lg font-semibold">
|
<div className="flex items-baseline justify-between">
|
||||||
{t("sectionAvailableCourses")}
|
<h3 className="text-lg font-semibold">
|
||||||
</h3>
|
{t("sectionAvailableCourses")}
|
||||||
<ElectiveTable
|
</h3>
|
||||||
items={filtered}
|
<span className="text-xs text-muted-foreground">
|
||||||
enrolling={enrollMutation.loading}
|
{t("total", { count: filteredAvailable.length })}
|
||||||
onEnroll={handleEnroll}
|
</span>
|
||||||
/>
|
</div>
|
||||||
</div>
|
{filteredAvailable.length === 0 && !loading ? (
|
||||||
|
emptyAvailableNode
|
||||||
|
) : (
|
||||||
|
<AvailableCardGrid
|
||||||
|
items={filteredAvailable}
|
||||||
|
enrolling={enrollMutation.loading}
|
||||||
|
onEnroll={handleEnroll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 退课确认对话框 */}
|
||||||
|
<AlertDialog open={dropDialogOpen} onOpenChange={setDropDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("dropDialogTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("dropDialogDescription", {
|
||||||
|
courseName: dropTarget?.name ?? "",
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<label
|
||||||
|
htmlFor="drop-reason"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{t("dropDialogReasonLabel")}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="drop-reason"
|
||||||
|
value={dropReason}
|
||||||
|
onChange={(e) => setDropReason(e.target.value)}
|
||||||
|
placeholder={t("dropDialogReasonPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
disabled={dropMutation.loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={closeDropDialog}
|
||||||
|
disabled={dropMutation.loading}
|
||||||
|
>
|
||||||
|
{t("dropDialogCancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDrop}
|
||||||
|
disabled={dropMutation.loading}
|
||||||
|
>
|
||||||
|
{dropMutation.loading
|
||||||
|
? t("dropDialogLoading")
|
||||||
|
: t("dropDialogConfirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可选课程表格(纯展示组件,对齐 §8.2 排版规范)。
|
* 我的选课 Card 网格(小屏 1 列,桌面 2-3 列)。
|
||||||
*/
|
*/
|
||||||
function ElectiveTable({
|
function SelectionCardGrid({
|
||||||
|
items,
|
||||||
|
dropping,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
items: Course[];
|
||||||
|
dropping: boolean;
|
||||||
|
onDrop: (course: Course) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((course) => (
|
||||||
|
<SelectionCard
|
||||||
|
key={course.id}
|
||||||
|
course={course}
|
||||||
|
dropping={dropping}
|
||||||
|
onDrop={onDrop}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的选课 Card 单卡片。
|
||||||
|
*/
|
||||||
|
function SelectionCard({
|
||||||
|
course,
|
||||||
|
dropping,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
course: Course;
|
||||||
|
dropping: boolean;
|
||||||
|
onDrop: (course: Course) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.elective.list");
|
||||||
|
const isFull = course.enrolled >= course.capacity;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/shell/student/elective/${course.id}`}
|
||||||
|
className="line-clamp-2 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{course.name}
|
||||||
|
</Link>
|
||||||
|
{course.subjectName ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("fieldSubject")}: {course.subjectName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
{course.status ? <StatusBadge status={course.status} /> : null}
|
||||||
|
{course.selectionMode ? (
|
||||||
|
<SelectionModeBadge mode={course.selectionMode} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.description ? (
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">{t("colTeacher")}</span>
|
||||||
|
: {course.teacher}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldCredits")}
|
||||||
|
</span>
|
||||||
|
: {course.credits}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldSchedule")}
|
||||||
|
</span>
|
||||||
|
: {course.schedule}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldCapacity")}
|
||||||
|
</span>
|
||||||
|
:{" "}
|
||||||
|
{t("enrolledCount", {
|
||||||
|
enrolled: course.enrolled,
|
||||||
|
capacity: course.capacity,
|
||||||
|
})}
|
||||||
|
{isFull ? (
|
||||||
|
<span className="ml-1 text-destructive">·{t("capacityFull")}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldCategory")}
|
||||||
|
</span>
|
||||||
|
: {course.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={dropping}
|
||||||
|
onClick={() => onDrop(course)}
|
||||||
|
>
|
||||||
|
{t("drop")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选课程 Card 网格。
|
||||||
|
*/
|
||||||
|
function AvailableCardGrid({
|
||||||
items,
|
items,
|
||||||
enrolling,
|
enrolling,
|
||||||
onEnroll,
|
onEnroll,
|
||||||
@@ -168,56 +486,187 @@ function ElectiveTable({
|
|||||||
enrolling: boolean;
|
enrolling: boolean;
|
||||||
onEnroll: (courseId: string) => void;
|
onEnroll: (courseId: string) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.elective.list");
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<table className="w-full text-sm">
|
{items.map((course) => (
|
||||||
<thead className="border-b bg-muted/30">
|
<AvailableCard
|
||||||
<tr>
|
key={course.id}
|
||||||
<th className="p-3 text-left font-medium">{t("colCourseName")}</th>
|
course={course}
|
||||||
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
|
enrolling={enrolling}
|
||||||
<th className="p-3 text-left font-medium">{t("colCapacity")}</th>
|
onEnroll={onEnroll}
|
||||||
<th className="p-3 text-left font-medium">{t("colEnrolled")}</th>
|
/>
|
||||||
<th className="p-3 text-left font-medium">{t("colSchedule")}</th>
|
))}
|
||||||
<th className="p-3 text-left font-medium">{t("colCredits")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("colCategory")}</th>
|
|
||||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{items.map((course) => {
|
|
||||||
const isFull = course.enrolled >= course.capacity;
|
|
||||||
return (
|
|
||||||
<tr key={course.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-3">
|
|
||||||
<Link
|
|
||||||
href={`/shell/student/elective/${course.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{course.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{course.teacher}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{course.capacity}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{course.enrolled}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{course.schedule}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{course.credits}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{course.category}</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={enrolling || isFull}
|
|
||||||
onClick={() => onEnroll(course.id)}
|
|
||||||
>
|
|
||||||
{t("enroll")}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选课程 Card 单卡片。
|
||||||
|
*/
|
||||||
|
function AvailableCard({
|
||||||
|
course,
|
||||||
|
enrolling,
|
||||||
|
onEnroll,
|
||||||
|
}: {
|
||||||
|
course: Course;
|
||||||
|
enrolling: boolean;
|
||||||
|
onEnroll: (courseId: string) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.elective.list");
|
||||||
|
const isFull = course.enrolled >= course.capacity;
|
||||||
|
const isClosed = course.status === "closed";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/shell/student/elective/${course.id}`}
|
||||||
|
className="line-clamp-2 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{course.name}
|
||||||
|
</Link>
|
||||||
|
{course.subjectName ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("fieldSubject")}: {course.subjectName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
{course.status ? <StatusBadge status={course.status} /> : null}
|
||||||
|
{course.selectionMode ? (
|
||||||
|
<SelectionModeBadge mode={course.selectionMode} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.description ? (
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">{t("colTeacher")}</span>
|
||||||
|
: {course.teacher}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldCredits")}
|
||||||
|
</span>
|
||||||
|
: {course.credits}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldSchedule")}
|
||||||
|
</span>
|
||||||
|
: {course.schedule}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldCapacity")}
|
||||||
|
</span>
|
||||||
|
:{" "}
|
||||||
|
{t("enrolledCount", {
|
||||||
|
enrolled: course.enrolled,
|
||||||
|
capacity: course.capacity,
|
||||||
|
})}
|
||||||
|
{isFull ? (
|
||||||
|
<span className="ml-1 text-destructive">·{t("capacityFull")}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t("fieldCategory")}
|
||||||
|
</span>
|
||||||
|
: {course.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={enrolling || isFull || isClosed}
|
||||||
|
onClick={() => onEnroll(course.id)}
|
||||||
|
>
|
||||||
|
{isClosed
|
||||||
|
? t("statusClosed")
|
||||||
|
: isFull
|
||||||
|
? t("capacityFull")
|
||||||
|
: t("enroll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程状态徽章(按状态色阶展示)。
|
||||||
|
*/
|
||||||
|
function StatusBadge({ status }: { status: CourseStatus }): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.elective.list");
|
||||||
|
const labelKey =
|
||||||
|
status === "open"
|
||||||
|
? "badgeStatusOpen"
|
||||||
|
: status === "closed"
|
||||||
|
? "badgeStatusClosed"
|
||||||
|
: status === "in_progress"
|
||||||
|
? "badgeStatusInProgress"
|
||||||
|
: "badgeStatusCompleted";
|
||||||
|
const cls = statusToBadgeClass(status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
|
||||||
|
cls
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选课模式徽章。
|
||||||
|
*/
|
||||||
|
function SelectionModeBadge({
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
mode: SelectionMode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.elective.list");
|
||||||
|
const labelKey = mode === "fcfs" ? "badgeModeFcfs" : "badgeModeLottery";
|
||||||
|
const cls =
|
||||||
|
mode === "fcfs"
|
||||||
|
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||||
|
: "bg-purple-500/10 text-purple-600 dark:text-purple-400";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
|
||||||
|
cls
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → 徽章色阶映射(对齐 §3.10 设计令牌规范)。
|
||||||
|
*/
|
||||||
|
function statusToBadgeClass(status: CourseStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "open":
|
||||||
|
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
case "closed":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
case "in_progress":
|
||||||
|
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||||
|
case "completed":
|
||||||
|
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生错题本页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
* 学生错题本页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 视图结构:
|
||||||
* - studentErrorBook(q, status, source, dueOnly):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* 1. 5 列统计卡(total / new / learning / mastered+掌握率 / toReview 高亮逾期)
|
||||||
* - markErrorMastered(id):✅ 已存在(来自 student.ts)
|
* 2. 筛选器(keyword / status / source / dueOnly / reset)+ 视图切换(card / table)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-error-book-v2
|
* 3. Card 网格(默认)或 Table(切换)
|
||||||
|
* 4. 详情弹窗(ErrorBookDetailDialog)+ 变式练习跳转
|
||||||
*
|
*
|
||||||
* URL 状态:?q=xxx &status=xxx &source=xxx &dueOnly=1
|
* URL 状态:?q=xxx &status=xxx &source=xxx &dueOnly=1
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
|
||||||
*
|
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { BookX } from "lucide-react";
|
import { BookX, LayoutGrid, List, RotateCcw } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -29,21 +27,28 @@ import { Button } from "@/shared/components/ui/button";
|
|||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
|
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { ErrorBookDetailDialog } from "./error-book-detail-dialog";
|
||||||
|
|
||||||
/** 错题状态枚举(@contract-pending,与 i18n status* key 对齐) */
|
/** 错题状态枚举(@contract-pending,与 i18n status* key 对齐) */
|
||||||
const STATUS_OPTIONS = ["new", "learning", "mastered", "to_review"] as const;
|
const STATUS_OPTIONS = [
|
||||||
|
"new",
|
||||||
|
"learning",
|
||||||
|
"mastered",
|
||||||
|
"to_review",
|
||||||
|
"archived",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/** 错题来源枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
/** 错题来源枚举(与 i18n source* key 对齐) */
|
||||||
const SOURCE_OPTIONS = ["exam", "homework", "practice"] as const;
|
const SOURCE_OPTIONS = ["exam", "homework", "practice", "manual"] as const;
|
||||||
|
|
||||||
|
/** 视图模式 */
|
||||||
|
type ViewMode = "card" | "table";
|
||||||
|
|
||||||
/**
|
|
||||||
* 错题本客户端主体。需由 server page 包裹在 <Suspense> 中
|
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
|
||||||
*/
|
|
||||||
export function StudentErrorBookListClient(): React.ReactElement {
|
export function StudentErrorBookListClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.errorBook");
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
@@ -56,10 +61,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
const source = searchParams.get("source") ?? "";
|
const source = searchParams.get("source") ?? "";
|
||||||
const dueOnly = searchParams.get("dueOnly") === "1";
|
const dueOnly = searchParams.get("dueOnly") === "1";
|
||||||
|
|
||||||
// 已掌握的本地乐观更新集合
|
|
||||||
const [masteredIds, setMasteredIds] = useState<Record<string, boolean>>({});
|
const [masteredIds, setMasteredIds] = useState<Record<string, boolean>>({});
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
||||||
|
const [selectedItem, setSelectedItem] = useState<StudentErrorBookItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending: MSW fallback
|
||||||
const { data, loading, error } = useStudentErrorBookV2({
|
const { data, loading, error } = useStudentErrorBookV2({
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
@@ -76,6 +84,8 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
return items.filter((item) => !masteredIds[item.id]);
|
return items.filter((item) => !masteredIds[item.id]);
|
||||||
}, [items, masteredIds]);
|
}, [items, masteredIds]);
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(q || status || source || dueOnly);
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
const updateQuery = (key: string, value: string): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -84,7 +94,7 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
params.delete(key);
|
params.delete(key);
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/shell/student/error-book?${params.toString()}`);
|
router.push("/shell/student/error-book?" + params.toString());
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +106,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
params.delete("dueOnly");
|
params.delete("dueOnly");
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/shell/student/error-book?${params.toString()}`);
|
router.push("/shell/student/error-book?" + params.toString());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = (): void => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push("/shell/student/error-book");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,13 +124,16 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
notify.success(t("markSuccess"));
|
notify.success(t("markSuccess"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.error(t("markError"));
|
notify.error(t("markError"));
|
||||||
// 保留错误日志便于诊断(不影响用户)
|
|
||||||
console.error("[student.error-book] markMastered failed:", err);
|
console.error("[student.error-book] markMastered failed:", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[markMastered, t],
|
[markMastered, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleViewDetail = useCallback((item: StudentErrorBookItem): void => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -123,7 +142,13 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const emptyNode = <EmptyState icon={BookX} title={t("emptyTitle")} />;
|
const emptyNode = (
|
||||||
|
<EmptyState
|
||||||
|
icon={BookX}
|
||||||
|
title={t("emptyTitle")}
|
||||||
|
description={t("emptyDescription")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
@@ -144,14 +169,11 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
aria-label={t("statusFilter")}
|
aria-label={t("statusFilter")}
|
||||||
>
|
>
|
||||||
<option value="">{t("allStatus")}</option>
|
<option value="">{t("allStatus")}</option>
|
||||||
{STATUS_OPTIONS.map((s) => {
|
{STATUS_OPTIONS.map((s) => (
|
||||||
const labelKey = statusOptionLabelKey(s);
|
<option key={s} value={s}>
|
||||||
return (
|
{t(statusOptionLabelKey(s))}
|
||||||
<option key={s} value={s}>
|
</option>
|
||||||
{t(labelKey)}
|
))}
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
@@ -162,7 +184,7 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
<option value="">{t("allSources")}</option>
|
<option value="">{t("allSources")}</option>
|
||||||
{SOURCE_OPTIONS.map((src) => (
|
{SOURCE_OPTIONS.map((src) => (
|
||||||
<option key={src} value={src}>
|
<option key={src} value={src}>
|
||||||
{src}
|
{t(sourceOptionLabelKey(src))}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -175,6 +197,46 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
{t("dueOnlyFilter")}
|
{t("dueOnlyFilter")}
|
||||||
</label>
|
</label>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1 size-3" />
|
||||||
|
{t("resetFilters")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1 rounded-md border border-input p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("viewCard")}
|
||||||
|
onClick={() => setViewMode("card")}
|
||||||
|
className={
|
||||||
|
"inline-flex h-8 items-center rounded px-2 text-xs " +
|
||||||
|
(viewMode === "card"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("viewTable")}
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
className={
|
||||||
|
"inline-flex h-8 items-center rounded px-2 text-xs " +
|
||||||
|
(viewMode === "table"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -189,8 +251,26 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{stats ? <StatsSection stats={stats} /> : null}
|
{stats ? <StatsSection stats={stats} /> : null}
|
||||||
<ErrorBookTable
|
{viewMode === "card" ? (
|
||||||
items={visibleItems}
|
<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}
|
onMarkMastered={handleMarkMastered}
|
||||||
marking={marking}
|
marking={marking}
|
||||||
/>
|
/>
|
||||||
@@ -198,7 +278,6 @@ export function StudentErrorBookListClient(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 错题状态枚举到 i18n key 的映射。 */
|
|
||||||
function statusOptionLabelKey(status: string): string {
|
function statusOptionLabelKey(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "new":
|
case "new":
|
||||||
@@ -209,12 +288,84 @@ function statusOptionLabelKey(status: string): string {
|
|||||||
return "statusMastered";
|
return "statusMastered";
|
||||||
case "to_review":
|
case "to_review":
|
||||||
return "statusToReview";
|
return "statusToReview";
|
||||||
|
case "archived":
|
||||||
|
return "statusArchived";
|
||||||
default:
|
default:
|
||||||
return "allStatus";
|
return "allStatus";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 统计卡片区。 */
|
function sourceOptionLabelKey(source: string): string {
|
||||||
|
switch (source) {
|
||||||
|
case "exam":
|
||||||
|
return "sourceExam";
|
||||||
|
case "homework":
|
||||||
|
return "sourceHomework";
|
||||||
|
case "practice":
|
||||||
|
return "sourcePractice";
|
||||||
|
case "manual":
|
||||||
|
return "sourceManual";
|
||||||
|
default:
|
||||||
|
return "allSources";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceOptionLabel(source: string, t: (key: string) => string): string {
|
||||||
|
const key = sourceOptionLabelKey(source);
|
||||||
|
return key === "allSources" ? source : t(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorBookStatusToBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "new":
|
||||||
|
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||||
|
case "learning":
|
||||||
|
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||||
|
case "mastered":
|
||||||
|
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
case "to_review":
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
case "archived":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function difficultyToBadgeClass(
|
||||||
|
difficulty: "easy" | "medium" | "hard",
|
||||||
|
): string {
|
||||||
|
switch (difficulty) {
|
||||||
|
case "easy":
|
||||||
|
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
case "medium":
|
||||||
|
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||||
|
case "hard":
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRateToPercent(rate: number): string {
|
||||||
|
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||||
|
return (rate * 100).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(isoDate: string): string {
|
||||||
|
if (!isoDate) return "--";
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (Number.isNaN(d.getTime())) return "--";
|
||||||
|
return d.toLocaleDateString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(nextReviewAt?: string): boolean {
|
||||||
|
if (!nextReviewAt) return false;
|
||||||
|
const d = new Date(nextReviewAt);
|
||||||
|
if (Number.isNaN(d.getTime())) return false;
|
||||||
|
return d.getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
function StatsSection({
|
function StatsSection({
|
||||||
stats,
|
stats,
|
||||||
}: {
|
}: {
|
||||||
@@ -222,32 +373,238 @@ function StatsSection({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.errorBook");
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
<StatsGrid columns={5}>
|
||||||
<StatCard title={t("fieldTotal")} value={String(stats.total)} />
|
|
||||||
<StatCard title={t("fieldNew")} value={String(stats.new)} />
|
|
||||||
<StatCard title={t("fieldLearning")} value={String(stats.learning)} />
|
|
||||||
<StatCard title={t("fieldMastered")} value={String(stats.mastered)} />
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("fieldMasteredRate")}
|
title={t("fieldTotal")}
|
||||||
value={formatRate(stats.masteredRate)}
|
value={String(stats.total)}
|
||||||
|
description={t("fieldTotalDesc")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldNew")}
|
||||||
|
value={String(stats.new)}
|
||||||
|
description={t("fieldNewDesc")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldLearning")}
|
||||||
|
value={String(stats.learning)}
|
||||||
|
description={t("fieldLearningDesc")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("fieldMastered")}
|
||||||
|
value={String(stats.mastered)}
|
||||||
|
description={t("fieldMasteredDesc", {
|
||||||
|
rate: formatRateToPercent(stats.masteredRate),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("fieldToReview")}
|
title={t("fieldToReview")}
|
||||||
value={String(stats.toReview)}
|
value={String(stats.toReview)}
|
||||||
|
description={t("fieldToReviewDesc")}
|
||||||
highlight={stats.toReview > 0}
|
highlight={stats.toReview > 0}
|
||||||
/>
|
/>
|
||||||
|
</StatsGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBookStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
|
const labelKey = statusOptionLabelKey(status);
|
||||||
|
const label = labelKey === "allStatus" ? status : t(labelKey);
|
||||||
|
const cls = errorBookStatusToBadgeClass(status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
|
||||||
|
cls
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DifficultyBadge({
|
||||||
|
difficulty,
|
||||||
|
}: {
|
||||||
|
difficulty: "easy" | "medium" | "hard";
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
|
const labelKey =
|
||||||
|
difficulty === "easy"
|
||||||
|
? "difficultyEasy"
|
||||||
|
: difficulty === "medium"
|
||||||
|
? "difficultyMedium"
|
||||||
|
: "difficultyHard";
|
||||||
|
const cls = difficultyToBadgeClass(difficulty);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
|
||||||
|
cls
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MasteryProgressBar({ level }: { level?: number }): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
|
const safeLevel = level ?? 0;
|
||||||
|
const clamped = Math.max(0, Math.min(5, safeLevel));
|
||||||
|
const percent = (clamped / 5) * 100;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: percent + "%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{clamped}/5</span>
|
||||||
|
<span className="sr-only">{t("masteryLevel")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 错题列表表格。 */
|
function ErrorBookCardGrid({
|
||||||
function ErrorBookTable({
|
|
||||||
items,
|
items,
|
||||||
onMarkMastered,
|
onMarkMastered,
|
||||||
|
onViewDetail,
|
||||||
marking,
|
marking,
|
||||||
}: {
|
}: {
|
||||||
items: StudentErrorBookItem[];
|
items: StudentErrorBookItem[];
|
||||||
onMarkMastered: (id: string) => Promise<void>;
|
onMarkMastered: (id: string) => Promise<void>;
|
||||||
|
onViewDetail: (item: StudentErrorBookItem) => void;
|
||||||
|
marking: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (items.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ErrorBookCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onMarkMastered={onMarkMastered}
|
||||||
|
onViewDetail={onViewDetail}
|
||||||
|
marking={marking}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBookCard({
|
||||||
|
item,
|
||||||
|
onMarkMastered,
|
||||||
|
onViewDetail,
|
||||||
|
marking,
|
||||||
|
}: {
|
||||||
|
item: StudentErrorBookItem;
|
||||||
|
onMarkMastered: (id: string) => Promise<void>;
|
||||||
|
onViewDetail: (item: StudentErrorBookItem) => void;
|
||||||
|
marking: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
|
const overdue = isOverdue(item.nextReviewAt);
|
||||||
|
const cardBorder = overdue ? "border-destructive" : "border";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex flex-col gap-3 rounded-xl " +
|
||||||
|
cardBorder +
|
||||||
|
" bg-card p-4 shadow-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="line-clamp-2 text-sm font-medium">{item.question}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span>{item.subject}</span>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span>{sourceOptionLabel(item.source, t)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorBookStatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{item.difficulty ? (
|
||||||
|
<DifficultyBadge difficulty={item.difficulty} />
|
||||||
|
) : null}
|
||||||
|
{(item.errorTags ?? []).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex h-5 items-center rounded border px-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{item.note ? (
|
||||||
|
<span
|
||||||
|
title={item.note}
|
||||||
|
className="inline-flex h-5 items-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{"\u270E"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<MasteryProgressBar level={item.masteryLevel} />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("reviewCount")}: {item.reviewCount ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("colLastErrorAt")}: {formatDate(item.lastErrorAt)}
|
||||||
|
</span>
|
||||||
|
{item.nextReviewAt ? (
|
||||||
|
<span className={overdue ? "font-medium text-destructive" : ""}>
|
||||||
|
{t("nextReviewAt")}: {formatDate(item.nextReviewAt)}
|
||||||
|
{overdue ? " (" + t("overdue") + ")" : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => onViewDetail(item)}
|
||||||
|
>
|
||||||
|
{t("viewDetail")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={marking || item.status === "mastered"}
|
||||||
|
onClick={() => void onMarkMastered(item.id)}
|
||||||
|
>
|
||||||
|
{t("markMastered")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBookTable({
|
||||||
|
items,
|
||||||
|
onMarkMastered,
|
||||||
|
onViewDetail,
|
||||||
|
marking,
|
||||||
|
}: {
|
||||||
|
items: StudentErrorBookItem[];
|
||||||
|
onMarkMastered: (id: string) => Promise<void>;
|
||||||
|
onViewDetail: (item: StudentErrorBookItem) => void;
|
||||||
marking: boolean;
|
marking: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.errorBook");
|
const t = useTranslations("studentDomain.errorBook");
|
||||||
@@ -263,6 +620,7 @@ function ErrorBookTable({
|
|||||||
<th className="p-3 text-left font-medium">{t("colLastErrorAt")}</th>
|
<th className="p-3 text-left font-medium">{t("colLastErrorAt")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colSource")}</th>
|
<th className="p-3 text-left font-medium">{t("colSource")}</th>
|
||||||
|
<th className="p-3 text-left font-medium">{t("masteryLevel")}</th>
|
||||||
<th className="p-3 text-left font-medium">{t("colActions")}</th>
|
<th className="p-3 text-left font-medium">{t("colActions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -270,15 +628,19 @@ function ErrorBookTable({
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-muted/30">
|
<tr key={item.id} className="hover:bg-muted/30">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<p className="font-medium line-clamp-2 max-w-md">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-md text-left font-medium line-clamp-2 hover:underline"
|
||||||
|
onClick={() => onViewDetail(item)}
|
||||||
|
>
|
||||||
{item.question}
|
{item.question}
|
||||||
</p>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">{item.subject}</td>
|
<td className="p-3 text-muted-foreground">{item.subject}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
item.errorCount >= 3 ? "text-destructive font-semibold" : ""
|
item.errorCount >= 3 ? "font-semibold text-destructive" : ""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.errorCount}
|
{item.errorCount}
|
||||||
@@ -291,18 +653,31 @@ function ErrorBookTable({
|
|||||||
<ErrorBookStatusBadge status={item.status} />
|
<ErrorBookStatusBadge status={item.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-xs text-muted-foreground">
|
<td className="p-3 text-xs text-muted-foreground">
|
||||||
{item.source}
|
{sourceOptionLabel(item.source, t)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Button
|
<MasteryProgressBar level={item.masteryLevel} />
|
||||||
size="sm"
|
</td>
|
||||||
variant="outline"
|
<td className="p-3">
|
||||||
className="h-8"
|
<div className="flex items-center gap-2">
|
||||||
disabled={marking || item.status === "mastered"}
|
<Button
|
||||||
onClick={() => void onMarkMastered(item.id)}
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
{t("markMastered")}
|
className="h-8"
|
||||||
</Button>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -311,52 +686,3 @@ function ErrorBookTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 错题状态徽章。 */
|
|
||||||
function ErrorBookStatusBadge({
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
status: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const t = useTranslations("studentDomain.errorBook");
|
|
||||||
const labelKey = statusOptionLabelKey(status);
|
|
||||||
const label = labelKey === "allStatus" ? status : t(labelKey);
|
|
||||||
const cls = errorBookStatusToBadgeClass(status);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据错题状态返回 Tailwind 徽章类名。 */
|
|
||||||
function errorBookStatusToBadgeClass(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "new":
|
|
||||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
|
||||||
case "learning":
|
|
||||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
|
||||||
case "mastered":
|
|
||||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
|
||||||
case "to_review":
|
|
||||||
return "bg-destructive/10 text-destructive";
|
|
||||||
default:
|
|
||||||
return "bg-muted text-muted-foreground";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化 0-1 的掌握率为百分比字符串。 */
|
|
||||||
function formatRate(rate: number): string {
|
|
||||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
|
||||||
return `${(rate * 100).toFixed(0)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化 ISO 日期字符串为本地化展示。 */
|
|
||||||
function formatDate(isoDate: string): string {
|
|
||||||
if (!isoDate) return "--";
|
|
||||||
const d = new Date(isoDate);
|
|
||||||
if (Number.isNaN(d.getTime())) return "--";
|
|
||||||
return d.toLocaleDateString("zh-CN");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,27 +2,15 @@
|
|||||||
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* 学生考试作答工作台 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
CheckCircle2,
|
||||||
*
|
ChevronLeft,
|
||||||
* 数据契约(@contract-pending 全 MSW):
|
ChevronRight,
|
||||||
* - studentExamTake(id) ❌ schema 无此根字段 → MSW 兜底
|
ClipboardCheck,
|
||||||
* - submitStudentExam(id, answers) mutation ❌ → MSW 兜底
|
Flag,
|
||||||
*
|
Timer,
|
||||||
* 三栏布局(WorkbenchPageShell):
|
} from "lucide-react";
|
||||||
* - left:题目导航(题号 / 已答 / 未答 / 已标记 状态)
|
import Link from "next/link";
|
||||||
* - center:当前题目作答区(单选 / 多选 / 填空 / 简答)
|
|
||||||
* - right:倒计时 + 提交按钮 + 作答统计
|
|
||||||
*
|
|
||||||
* 答案本地暂存:localStorage key `exam-answers-${id}`,防刷新丢失
|
|
||||||
* 倒计时:基于 duration(分钟),到 0 自动提交
|
|
||||||
* 提交确认:window.confirm 二次确认
|
|
||||||
*
|
|
||||||
* 三态规范(§11.3 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 { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -50,7 +38,9 @@ interface PersistedState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 作答工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 学生考试作答工作台(ARCHITECTURE.md §7.3 / §10 P2)。
|
||||||
|
*
|
||||||
|
* 三栏布局 + 倒计时自动提交 + localStorage 暂存 + ConfirmDialog 二次确认。
|
||||||
*/
|
*/
|
||||||
export function StudentExamTakeClient(): React.ReactElement {
|
export function StudentExamTakeClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.take");
|
const t = useTranslations("studentDomain.exams.take");
|
||||||
@@ -59,7 +49,6 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const examId = params?.id ?? "";
|
const examId = params?.id ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
|
||||||
const { data, loading, error } = useStudentExamTake(examId);
|
const { data, loading, error } = useStudentExamTake(examId);
|
||||||
const submitMutation = useSubmitStudentExam();
|
const submitMutation = useSubmitStudentExam();
|
||||||
|
|
||||||
@@ -68,17 +57,19 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
const [currentNo, setCurrentNo] = useState<number>(1);
|
const [currentNo, setCurrentNo] = useState<number>(1);
|
||||||
const [secondsLeft, setSecondsLeft] = useState<number>(0);
|
const [secondsLeft, setSecondsLeft] = useState<number>(0);
|
||||||
const [hydrated, setHydrated] = useState<boolean>(false);
|
const [hydrated, setHydrated] = useState<boolean>(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState<boolean>(false);
|
||||||
|
const [autoSaveStatus, setAutoSaveStatus] = useState<
|
||||||
|
"idle" | "saving" | "saved"
|
||||||
|
>("idle");
|
||||||
|
|
||||||
const storageKey = `${STORAGE_KEY_PREFIX}${examId}`;
|
const storageKey = `${STORAGE_KEY_PREFIX}${examId}`;
|
||||||
const submittedRef = useRef<boolean>(false);
|
const submittedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// 首次拿到数据时初始化倒计时
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
setSecondsLeft((prev) => (prev > 0 ? prev : data.duration * 60));
|
setSecondsLeft((prev) => (prev > 0 ? prev : data.duration * 60));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// 从 localStorage 恢复暂存答案(仅客户端执行)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!examId) return;
|
if (!examId) return;
|
||||||
try {
|
try {
|
||||||
@@ -88,21 +79,28 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
setAnswers(parsed.answers ?? {});
|
setAnswers(parsed.answers ?? {});
|
||||||
setMarked(parsed.marked ?? []);
|
setMarked(parsed.marked ?? []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// localStorage 不可用或 JSON 损坏:忽略,从空开始
|
console.warn("[portal-shell] restore exam answers failed:", err);
|
||||||
}
|
}
|
||||||
setHydrated(true);
|
setHydrated(true);
|
||||||
}, [examId, storageKey]);
|
}, [examId, storageKey]);
|
||||||
|
|
||||||
// 答案变化时写入 localStorage(防刷新丢失)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated) return;
|
if (!hydrated) return;
|
||||||
try {
|
setAutoSaveStatus("saving");
|
||||||
const payload: PersistedState = { answers, marked };
|
const timer = window.setTimeout(() => {
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
try {
|
||||||
} catch {
|
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]);
|
}, [answers, marked, hydrated, storageKey]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
@@ -110,8 +108,7 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
if (submittedRef.current) return;
|
if (submittedRef.current) return;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (!isAuto) {
|
if (!isAuto) {
|
||||||
const confirmed = window.confirm(t("submitConfirm"));
|
setConfirmOpen(false);
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
}
|
||||||
submittedRef.current = true;
|
submittedRef.current = true;
|
||||||
try {
|
try {
|
||||||
@@ -119,8 +116,8 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
notify.success(t("submitSuccess"));
|
notify.success(t("submitSuccess"));
|
||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(storageKey);
|
window.localStorage.removeItem(storageKey);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// 清理失败不影响流程
|
console.warn("[portal-shell] clear exam answers failed:", err);
|
||||||
}
|
}
|
||||||
router.push(`/shell/student/exams/${examId}/result`);
|
router.push(`/shell/student/exams/${examId}/result`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -132,7 +129,6 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
[answers, data, examId, router, storageKey, submitMutation, t],
|
[answers, data, examId, router, storageKey, submitMutation, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 倒计时:每秒递减,归零自动提交
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || secondsLeft <= 0) return;
|
if (!data || secondsLeft <= 0) return;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -140,6 +136,7 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
if (!submittedRef.current) {
|
if (!submittedRef.current) {
|
||||||
|
notify.warning(t("timeUpAutoSubmit"));
|
||||||
void handleSubmit(true);
|
void handleSubmit(true);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -150,7 +147,7 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [data, secondsLeft, handleSubmit]);
|
}, [data, secondsLeft, handleSubmit, t]);
|
||||||
|
|
||||||
const questions = data?.questions ?? [];
|
const questions = data?.questions ?? [];
|
||||||
|
|
||||||
@@ -158,12 +155,11 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
const answeredCount = questions.filter(
|
const answeredCount = questions.filter(
|
||||||
(q) => answers[String(q.questionNo)]?.trim().length,
|
(q) => answers[String(q.questionNo)]?.trim().length,
|
||||||
).length;
|
).length;
|
||||||
const markedCount = marked.length;
|
|
||||||
return {
|
return {
|
||||||
total: questions.length,
|
total: questions.length,
|
||||||
answered: answeredCount,
|
answered: answeredCount,
|
||||||
unanswered: questions.length - answeredCount,
|
unanswered: questions.length - answeredCount,
|
||||||
marked: markedCount,
|
marked: marked.length,
|
||||||
};
|
};
|
||||||
}, [questions, answers, marked]);
|
}, [questions, answers, marked]);
|
||||||
|
|
||||||
@@ -185,77 +181,91 @@ export function StudentExamTakeClient(): React.ReactElement {
|
|||||||
const currentQuestion = questions.find((q) => q.questionNo === currentNo);
|
const currentQuestion = questions.find((q) => q.questionNo === currentNo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkbenchPageShell
|
<>
|
||||||
title={data?.title ?? t("title")}
|
<WorkbenchPageShell
|
||||||
icon={<ClipboardCheck className="size-6" />}
|
title={data?.title ?? t("title")}
|
||||||
loading={loading}
|
description={
|
||||||
loadingNode={<WorkbenchPageSkeleton />}
|
data ? `${t("totalScore")}: ${data.totalScore}` : undefined
|
||||||
errorNode={errorNode ?? notFoundNode}
|
}
|
||||||
left={
|
icon={<ClipboardCheck className="size-6" />}
|
||||||
data ? (
|
actions={
|
||||||
<QuestionNavPanel
|
<Button asChild variant="ghost" size="sm">
|
||||||
questions={questions}
|
<Link href="/shell/student/exams">
|
||||||
currentNo={currentNo}
|
<ChevronLeft className="mr-1 size-4" />
|
||||||
answers={answers}
|
{t("back")}
|
||||||
marked={marked}
|
</Link>
|
||||||
onSelect={setCurrentNo}
|
</Button>
|
||||||
/>
|
}
|
||||||
) : null
|
loading={loading}
|
||||||
}
|
loadingNode={<WorkbenchPageSkeleton />}
|
||||||
center={
|
errorNode={errorNode ?? notFoundNode}
|
||||||
data && currentQuestion ? (
|
left={
|
||||||
<QuestionAnswerPanel
|
data ? (
|
||||||
question={currentQuestion}
|
<QuestionNavPanel
|
||||||
answer={answers[String(currentQuestion.questionNo)] ?? ""}
|
questions={questions}
|
||||||
marked={marked.includes(currentQuestion.questionNo)}
|
currentNo={currentNo}
|
||||||
onAnswerChange={(v) =>
|
answers={answers}
|
||||||
setAnswers((prev) => ({
|
marked={marked}
|
||||||
...prev,
|
onSelect={setCurrentNo}
|
||||||
[String(currentQuestion.questionNo)]: v,
|
/>
|
||||||
}))
|
) : null
|
||||||
}
|
}
|
||||||
onToggleMark={() => {
|
center={
|
||||||
setMarked((prev) =>
|
data && currentQuestion ? (
|
||||||
prev.includes(currentQuestion.questionNo)
|
<QuestionAnswerPanel
|
||||||
? prev.filter((n) => n !== currentQuestion.questionNo)
|
question={currentQuestion}
|
||||||
: [...prev, currentQuestion.questionNo],
|
answer={answers[String(currentQuestion.questionNo)] ?? ""}
|
||||||
);
|
marked={marked.includes(currentQuestion.questionNo)}
|
||||||
}}
|
onAnswerChange={(v) =>
|
||||||
onPrev={() => {
|
setAnswers((prev) => ({
|
||||||
const prevNo = currentQuestion.questionNo - 1;
|
...prev,
|
||||||
if (prevNo >= 1) setCurrentNo(prevNo);
|
[String(currentQuestion.questionNo)]: v,
|
||||||
}}
|
}))
|
||||||
onNext={() => {
|
}
|
||||||
const nextNo = currentQuestion.questionNo + 1;
|
onToggleMark={() => {
|
||||||
if (nextNo <= questions.length) setCurrentNo(nextNo);
|
setMarked((prev) =>
|
||||||
}}
|
prev.includes(currentQuestion.questionNo)
|
||||||
hasPrev={currentQuestion.questionNo > 1}
|
? prev.filter((n) => n !== currentQuestion.questionNo)
|
||||||
hasNext={currentQuestion.questionNo < questions.length}
|
: [...prev, currentQuestion.questionNo],
|
||||||
/>
|
);
|
||||||
) : null
|
}}
|
||||||
}
|
onPrev={() => {
|
||||||
right={
|
const prevNo = currentQuestion.questionNo - 1;
|
||||||
data ? (
|
if (prevNo >= 1) setCurrentNo(prevNo);
|
||||||
<TimerSubmitPanel
|
}}
|
||||||
secondsLeft={secondsLeft}
|
onNext={() => {
|
||||||
stats={stats}
|
const nextNo = currentQuestion.questionNo + 1;
|
||||||
submitting={submitMutation.loading}
|
if (nextNo <= questions.length) setCurrentNo(nextNo);
|
||||||
onSubmit={() => void handleSubmit(false)}
|
}}
|
||||||
/>
|
hasPrev={currentQuestion.questionNo > 1}
|
||||||
) : null
|
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({
|
function QuestionNavPanel({
|
||||||
questions,
|
questions,
|
||||||
currentNo,
|
currentNo,
|
||||||
@@ -271,12 +281,15 @@ function QuestionNavPanel({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.take");
|
const t = useTranslations("studentDomain.exams.take");
|
||||||
const markedSet = new Set(marked);
|
const markedSet = new Set(marked);
|
||||||
|
const answeredCount = questions.filter(
|
||||||
|
(q) => (answers[String(q.questionNo)] ?? "").trim().length > 0,
|
||||||
|
).length;
|
||||||
return (
|
return (
|
||||||
<WorkbenchPanel
|
<WorkbenchPanel
|
||||||
title={t("questionNav")}
|
title={t("questionNav")}
|
||||||
actions={
|
actions={
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t("answered")} {Object.keys(answers).length} / {questions.length}
|
{t("answered")} {answeredCount} / {questions.length}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -297,7 +310,7 @@ function QuestionNavPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(q.questionNo)}
|
onClick={() => onSelect(q.questionNo)}
|
||||||
className={`relative h-9 rounded-md border text-sm transition-colors hover:bg-accent ${cls}`}
|
className={`relative h-9 rounded-md border text-sm transition-colors hover:bg-accent ${cls}`}
|
||||||
aria-label={`Question ${q.questionNo}`}
|
aria-label={`${t("jumpToQuestion")} ${q.questionNo}`}
|
||||||
aria-current={isCurrent ? "true" : undefined}
|
aria-current={isCurrent ? "true" : undefined}
|
||||||
>
|
>
|
||||||
{q.questionNo}
|
{q.questionNo}
|
||||||
@@ -315,11 +328,6 @@ function QuestionNavPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 中栏:当前题目作答面板。
|
|
||||||
*
|
|
||||||
* 含:题号 / 题干 / 分值 / 答题区(按题型) / 上一题/下一题 / 标记按钮。
|
|
||||||
*/
|
|
||||||
function QuestionAnswerPanel({
|
function QuestionAnswerPanel({
|
||||||
question,
|
question,
|
||||||
answer,
|
answer,
|
||||||
@@ -344,7 +352,7 @@ function QuestionAnswerPanel({
|
|||||||
const t = useTranslations("studentDomain.exams.take");
|
const t = useTranslations("studentDomain.exams.take");
|
||||||
return (
|
return (
|
||||||
<WorkbenchPanel
|
<WorkbenchPanel
|
||||||
title={`${question.questionNo}.`}
|
title={`${question.questionNo}. ${t("questionUnit")}`}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -361,7 +369,8 @@ function QuestionAnswerPanel({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">{question.question}</p>
|
<p className="text-sm font-medium">{question.question}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{question.type} · {question.score}
|
{t("questionType")}: {question.type} · {t("score")}:{" "}
|
||||||
|
{question.score}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -378,9 +387,10 @@ function QuestionAnswerPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={!hasPrev}
|
disabled={!hasPrev}
|
||||||
aria-label="previous"
|
aria-label={t("prevQuestion")}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
|
{t("prevQuestion")}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p>
|
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -389,8 +399,9 @@ function QuestionAnswerPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
aria-label="next"
|
aria-label={t("nextQuestion")}
|
||||||
>
|
>
|
||||||
|
{t("nextQuestion")}
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,14 +410,6 @@ function QuestionAnswerPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 按题型渲染对应答题输入。
|
|
||||||
*
|
|
||||||
* - single_choice:单选(radio)
|
|
||||||
* - multiple_choice:多选(checkbox,逗号分隔)
|
|
||||||
* - fill_blank:填空(text input)
|
|
||||||
* - essay:简答(textarea)
|
|
||||||
*/
|
|
||||||
function AnswerInput({
|
function AnswerInput({
|
||||||
question,
|
question,
|
||||||
answer,
|
answer,
|
||||||
@@ -445,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 (
|
return (
|
||||||
<Input
|
<textarea
|
||||||
type="text"
|
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
value={answer}
|
value={answer}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 单选输入:从 options 中选一项,answer 为选项索引(字符串)。
|
|
||||||
*/
|
|
||||||
function SingleChoiceInput({
|
function SingleChoiceInput({
|
||||||
options,
|
options,
|
||||||
answer,
|
answer,
|
||||||
@@ -499,9 +490,6 @@ function SingleChoiceInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 多选输入:可勾选多项,answer 为逗号分隔的索引字符串(升序去重)。
|
|
||||||
*/
|
|
||||||
function MultipleChoiceInput({
|
function MultipleChoiceInput({
|
||||||
options,
|
options,
|
||||||
answer,
|
answer,
|
||||||
@@ -554,13 +542,11 @@ function MultipleChoiceInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 右栏:倒计时 + 作答统计 + 提交按钮。
|
|
||||||
*/
|
|
||||||
function TimerSubmitPanel({
|
function TimerSubmitPanel({
|
||||||
secondsLeft,
|
secondsLeft,
|
||||||
stats,
|
stats,
|
||||||
submitting,
|
submitting,
|
||||||
|
autoSaveStatus,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
secondsLeft: number;
|
secondsLeft: number;
|
||||||
@@ -571,25 +557,62 @@ function TimerSubmitPanel({
|
|||||||
marked: number;
|
marked: number;
|
||||||
};
|
};
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
|
autoSaveStatus: "idle" | "saving" | "saved";
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.take");
|
const t = useTranslations("studentDomain.exams.take");
|
||||||
const timeStr = formatTime(secondsLeft);
|
const timeStr = formatTime(secondsLeft);
|
||||||
const urgent = secondsLeft > 0 && secondsLeft <= 60;
|
const urgent = secondsLeft > 0 && secondsLeft <= 60;
|
||||||
|
const expired = secondsLeft <= 0;
|
||||||
return (
|
return (
|
||||||
<WorkbenchPanel title={t("timeRemaining")}>
|
<WorkbenchPanel title={t("timeRemaining")}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border bg-background p-4 text-center">
|
<div
|
||||||
|
className={`rounded-md border p-4 text-center ${
|
||||||
|
expired
|
||||||
|
? "border-destructive bg-destructive/10"
|
||||||
|
: urgent
|
||||||
|
? "border-destructive bg-destructive/5 animate-pulse"
|
||||||
|
: "bg-background"
|
||||||
|
}`}
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("timeRemaining")}
|
||||||
|
>
|
||||||
|
<Timer
|
||||||
|
className={`mx-auto mb-1 size-5 ${
|
||||||
|
urgent || expired ? "text-destructive" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
<p
|
<p
|
||||||
className={`font-mono text-2xl font-semibold ${
|
className={`font-mono text-2xl font-semibold ${
|
||||||
urgent ? "text-destructive" : "text-foreground"
|
urgent || expired ? "text-destructive" : "text-foreground"
|
||||||
}`}
|
}`}
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
{timeStr}
|
{timeStr}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-background p-3">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs">
|
||||||
|
{autoSaveStatus === "saving" ? (
|
||||||
|
<>
|
||||||
|
<span className="size-2 animate-pulse rounded-full bg-amber-500" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("autoSaveSaving")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : autoSaveStatus === "saved" ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 text-emerald-600" />
|
||||||
|
<span className="text-emerald-600">{t("autoSaveTip")}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t("autoSaveIdle")}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-md border bg-background p-4 text-sm">
|
<div className="space-y-2 rounded-md border bg-background p-4 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">{t("answered")}</span>
|
<span className="text-muted-foreground">{t("answered")}</span>
|
||||||
@@ -616,15 +639,57 @@ function TimerSubmitPanel({
|
|||||||
{t("submit")}
|
{t("submit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{t("autoSaveTip")}</p>
|
<p className="text-xs text-muted-foreground">{t("makeSureAnswered")}</p>
|
||||||
</div>
|
</div>
|
||||||
</WorkbenchPanel>
|
</WorkbenchPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function ConfirmDialog({
|
||||||
* 将秒数格式化为 HH:MM:SS。
|
unansweredCount,
|
||||||
*/
|
isBusy,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
unansweredCount: number;
|
||||||
|
isBusy: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.exams.take");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("confirmSubmit")}
|
||||||
|
>
|
||||||
|
<div className="mx-4 w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg">
|
||||||
|
<h3 className="text-base font-semibold">{t("confirmSubmit")}</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{unansweredCount > 0
|
||||||
|
? t("unansweredWarning", { count: unansweredCount })
|
||||||
|
: t("confirmSubmitDescription")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{tCommon("button.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={isBusy} onClick={onConfirm}>
|
||||||
|
{isBusy ? t("submitting") : t("confirmSubmitAction")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(totalSeconds: number): string {
|
function formatTime(totalSeconds: number): string {
|
||||||
const safe = Math.max(0, Math.floor(totalSeconds));
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
||||||
const h = Math.floor(safe / 3600);
|
const h = Math.floor(safe / 3600);
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
// @contract-pending:studentExams 查询契约待补齐,全 MSW 兜底
|
|
||||||
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生考试列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
* 学生考试列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - studentExams(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentExams(status) ➗ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
*
|
*
|
||||||
* URL 状态:?status=xxx
|
* 视图:
|
||||||
* 状态过滤:all / upcoming / inProgress / ended / scored
|
* - 按状态分组(进行中 / 即将开始 / 已结束 / 已出分)
|
||||||
|
* - 学科筛选(pill 按钮组)
|
||||||
|
* - 每张卡片:标题 + 学科 + 考试时间 + 时长 + 考场 + 座位号 + 状态徽章
|
||||||
|
* - 即将开始:倒计时提示(24h 内高亮)
|
||||||
|
* - 进行中:进入考试(跳 take)
|
||||||
|
* - 已出分/已结束:查看结果(跳 result)
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
@@ -17,8 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useMemo } from "react";
|
||||||
import { useTransition } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useStudentExams, type StudentExam } from "@/lib/api";
|
import { useStudentExams, type StudentExam } from "@/lib/api";
|
||||||
@@ -27,49 +29,74 @@ import {
|
|||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
/** 状态分组配置(顺序即展示顺序)。 */
|
||||||
"all",
|
const STATUS_GROUPS = ["inProgress", "upcoming", "ended", "scored"] as const;
|
||||||
"upcoming",
|
type StatusGroup = (typeof STATUS_GROUPS)[number];
|
||||||
"inProgress",
|
|
||||||
"ended",
|
/** 将后端 status 归一到分组 key。 */
|
||||||
"scored",
|
function toGroup(status: string): StatusGroup {
|
||||||
] as const;
|
const s = status.toLowerCase();
|
||||||
type StatusFilter = (typeof STATUS_OPTIONS)[number];
|
if (s.includes("progress") || s === "in_progress") return "inProgress";
|
||||||
|
if (s.includes("upcoming") || s.includes("not_started")) return "upcoming";
|
||||||
|
if (s.includes("scored") || s.includes("graded")) return "scored";
|
||||||
|
return "ended";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 倒计时文案(基于考试日期与当前时间差)。 */
|
||||||
|
type Translator = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
function getCountdownLabel(
|
||||||
|
examDate: string,
|
||||||
|
t: Translator,
|
||||||
|
): { label: string; urgent: boolean } {
|
||||||
|
const target = new Date(examDate).getTime();
|
||||||
|
if (Number.isNaN(target)) return { label: "", urgent: false };
|
||||||
|
const diff = target - Date.now();
|
||||||
|
if (diff <= 0) return { label: t("countdownStarted"), urgent: false };
|
||||||
|
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
||||||
|
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||||
|
if (days >= 1)
|
||||||
|
return { label: t("countdownStartsIn", { n: days }), urgent: false };
|
||||||
|
if (hours >= 1)
|
||||||
|
return { label: t("countdownHoursLeft", { n: hours }), urgent: true };
|
||||||
|
return { label: t("countdownUrgent"), urgent: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
|
||||||
*/
|
*/
|
||||||
export function StudentExamsListClient(): React.ReactElement {
|
export function StudentExamsListClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.list");
|
const t = useTranslations("studentDomain.exams.list");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const statusParam = searchParams.get("status") ?? "all";
|
// @contract-pending:MSW 兜底,不传 status 取全量后客户端分组
|
||||||
const statusFilter: StatusFilter = (
|
const { data, loading, error } = useStudentExams({});
|
||||||
STATUS_OPTIONS as readonly string[]
|
|
||||||
).includes(statusParam)
|
|
||||||
? (statusParam as StatusFilter)
|
|
||||||
: "all";
|
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
const exams = data ?? [];
|
||||||
const { data, loading, error } = useStudentExams(
|
|
||||||
statusFilter === "all" ? {} : { status: statusFilter },
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
// 学科选项(去重,保留出现顺序)
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const subjectOptions = useMemo<string[]>(() => {
|
||||||
if (value && value !== "all") {
|
const seen = new Set<string>();
|
||||||
params.set(key, value);
|
const list: string[] = [];
|
||||||
} else {
|
for (const e of exams) {
|
||||||
params.delete(key);
|
if (!seen.has(e.subject)) {
|
||||||
|
seen.add(e.subject);
|
||||||
|
list.push(e.subject);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
return list;
|
||||||
router.push(`/shell/student/exams?${params.toString()}`);
|
}, [exams]);
|
||||||
});
|
|
||||||
};
|
// 按状态分组
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<StatusGroup, StudentExam[]>();
|
||||||
|
for (const g of STATUS_GROUPS) map.set(g, []);
|
||||||
|
for (const e of exams) {
|
||||||
|
const g = toGroup(e.status);
|
||||||
|
map.get(g)?.push(e);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [exams]);
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
@@ -86,139 +113,247 @@ export function StudentExamsListClient(): React.ReactElement {
|
|||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<FileText className="size-6" />}
|
icon={<FileText className="size-6" />}
|
||||||
filters={
|
filters={
|
||||||
<select
|
<SubjectFilterPills
|
||||||
value={statusFilter}
|
options={subjectOptions}
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
disabled={exams.length === 0}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
/>
|
||||||
aria-label={t("statusFilter")}
|
|
||||||
>
|
|
||||||
<option value="all">{t("allStatus")}</option>
|
|
||||||
<option value="upcoming">{t("statusUpcoming")}</option>
|
|
||||||
<option value="inProgress">{t("statusInProgress")}</option>
|
|
||||||
<option value="ended">{t("statusEnded")}</option>
|
|
||||||
<option value="scored">{t("statusScored")}</option>
|
|
||||||
</select>
|
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={4} />}
|
||||||
empty={(data?.length ?? 0) === 0 && !loading}
|
empty={!loading && !error && exams.length === 0}
|
||||||
emptyNode={
|
emptyNode={<ExamsEmptyState />}
|
||||||
<div className="rounded-xl border p-10 text-center text-muted-foreground">
|
|
||||||
{t("emptyTitle")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
<span>{t("total", { count: data?.length ?? 0 })}</span>
|
<span>{t("total", { count: exams.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExamsTable items={data ?? []} />
|
<div className="space-y-8">
|
||||||
|
{STATUS_GROUPS.map((g) => {
|
||||||
|
const items = grouped.get(g) ?? [];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return <ExamStatusGroup key={g} group={g} items={items} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 学科筛选 pill 按钮组(客户端状态由 URL 管理,此处仅展示按钮组容器)。 */
|
||||||
* 学生考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
function SubjectFilterPills({
|
||||||
*/
|
options,
|
||||||
function ExamsTable({ items }: { items: StudentExam[] }): React.ReactElement {
|
disabled,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
disabled: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.list");
|
const t = useTranslations("studentDomain.exams.list");
|
||||||
|
if (disabled) return <span className="text-xs text-muted-foreground" />;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div
|
||||||
<table className="w-full text-sm">
|
className="flex flex-wrap items-center gap-2"
|
||||||
<thead className="border-b bg-muted/30">
|
role="group"
|
||||||
<tr>
|
aria-label={t("subjectFilter")}
|
||||||
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
|
>
|
||||||
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
<span className="text-xs text-muted-foreground">
|
||||||
<th className="p-3 text-left font-medium">{t("colExamDate")}</th>
|
{t("subjectFilter")}:
|
||||||
<th className="p-3 text-left font-medium">{t("colDuration")}</th>
|
</span>
|
||||||
<th className="p-3 text-left font-medium">{t("colLocation")}</th>
|
{options.map((subj) => (
|
||||||
<th className="p-3 text-left font-medium">{t("colSeatNo")}</th>
|
<span
|
||||||
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
key={subj}
|
||||||
<th className="p-3 text-left font-medium">{t("colScore")}</th>
|
className="inline-flex h-7 items-center rounded-full border bg-background px-3 text-xs text-muted-foreground"
|
||||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
>
|
||||||
</tr>
|
{subj}
|
||||||
</thead>
|
</span>
|
||||||
<tbody className="divide-y">
|
))}
|
||||||
{items.map((exam) => (
|
|
||||||
<tr key={exam.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">{exam.title}</td>
|
|
||||||
<td className="p-3">{exam.subject}</td>
|
|
||||||
<td className="p-3 font-mono text-xs">{exam.examDate}</td>
|
|
||||||
<td className="p-3 text-xs">{exam.duration}</td>
|
|
||||||
<td className="p-3">{exam.location}</td>
|
|
||||||
<td className="p-3">{exam.seatNo}</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<ExamStatusBadge status={exam.status} />
|
|
||||||
</td>
|
|
||||||
<td className="p-3">{exam.score === null ? "--" : exam.score}</td>
|
|
||||||
<td className="p-3 text-right">{renderAction(exam)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 按状态分组的考试卡片组。 */
|
||||||
* 按考试状态渲染对应操作链接:
|
function ExamStatusGroup({
|
||||||
* - upcoming → 进入考试(跳 take)
|
group,
|
||||||
* - 其他(进行中/已结束/已出分)→ 查看结果(跳 result)
|
items,
|
||||||
*/
|
}: {
|
||||||
function renderAction(exam: StudentExam): React.ReactElement {
|
group: StatusGroup;
|
||||||
|
items: StudentExam[];
|
||||||
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.list");
|
const t = useTranslations("studentDomain.exams.list");
|
||||||
if (exam.status === "upcoming") {
|
const labelKey = `group${group.charAt(0).toUpperCase()}${group.slice(1)}`;
|
||||||
return (
|
const descKey = `${labelKey}Desc`;
|
||||||
<Link
|
const label = t(labelKey as Parameters<typeof t>[0]);
|
||||||
href={`/shell/student/exams/${exam.id}/take`}
|
const desc = t(descKey as Parameters<typeof t>[0]);
|
||||||
className="text-xs text-primary hover:underline"
|
return (
|
||||||
>
|
<section aria-label={label}>
|
||||||
{t("takeExam")}
|
<div className="mb-3 flex items-baseline gap-3">
|
||||||
</Link>
|
<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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/shell/student/exams/${exam.id}/result`}
|
href={linkHref}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
className="block h-full focus-visible:outline-2"
|
||||||
|
aria-label={`${exam.title} - ${isTakeable ? t("takeExam") : t("viewResult")}`}
|
||||||
>
|
>
|
||||||
{t("viewResult")}
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 考试状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。 */
|
||||||
* 学生考试状态徽章(按状态色阶展示)。
|
function examStatusBadgeClass(group: StatusGroup): string {
|
||||||
*/
|
switch (group) {
|
||||||
function ExamStatusBadge({ status }: { status: string }): React.ReactElement {
|
case "inProgress":
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
case "upcoming":
|
||||||
|
return "bg-primary/10 text-primary";
|
||||||
|
case "scored":
|
||||||
|
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 考试状态 → 本地化标签。 */
|
||||||
|
function examStatusLabel(group: StatusGroup, t: (k: string) => string): string {
|
||||||
|
switch (group) {
|
||||||
|
case "inProgress":
|
||||||
|
return t("statusInProgress");
|
||||||
|
case "upcoming":
|
||||||
|
return t("statusUpcoming");
|
||||||
|
case "scored":
|
||||||
|
return t("statusScored");
|
||||||
|
default:
|
||||||
|
return t("statusEnded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空状态。 */
|
||||||
|
function ExamsEmptyState(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.exams.list");
|
const t = useTranslations("studentDomain.exams.list");
|
||||||
const label =
|
|
||||||
status === "upcoming"
|
|
||||||
? t("statusUpcoming")
|
|
||||||
: status === "inProgress"
|
|
||||||
? t("statusInProgress")
|
|
||||||
: status === "ended"
|
|
||||||
? t("statusEnded")
|
|
||||||
: status === "scored"
|
|
||||||
? t("statusScored")
|
|
||||||
: status;
|
|
||||||
const cls =
|
|
||||||
status === "upcoming"
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: status === "inProgress"
|
|
||||||
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
|
||||||
: status === "ended"
|
|
||||||
? "bg-muted text-muted-foreground"
|
|
||||||
: status === "scored"
|
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "bg-muted text-muted-foreground";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<div className="flex min-h-80 flex-col items-center justify-center gap-2 rounded-xl border p-8 text-center">
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
<p className="text-lg font-semibold">{t("emptyTitle")}</p>
|
||||||
>
|
<p className="text-sm text-muted-foreground">{t("emptyDesc")}</p>
|
||||||
{label}
|
</div>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,22 @@
|
|||||||
* - studentGrades(subject, type, q):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentGrades(subject, type, q):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-grades-list
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-grades-list
|
||||||
*
|
*
|
||||||
* URL 状态:?subject=xxx &type=xxx &q=xxx
|
* 视图结构(复刻 CICD my-grades/page.tsx 数据维度并扩展):
|
||||||
|
* 1. 4 维筛选(subject / type / semester / keyword)
|
||||||
|
* 2. 成绩汇总卡(记录数 / 平均得分率 / 及格率 / 优秀率)
|
||||||
|
* 3. 成绩趋势卡(SVG 折线图,学生得分率随时间变化;班级平均线 @contract-pending)
|
||||||
|
* 4. 排名趋势卡(SVG 折线图,Y 轴反向,展示班级排名变化)
|
||||||
|
* 5. 班级分布图(SVG 柱状图,高亮当前学生所在分数段)
|
||||||
|
* 6. 成长档案(SVG 多折线图,跨学期各学科成绩变化)
|
||||||
|
* 7. 成绩明细表(学科/类型/得分/满分/得分率/等级/日期/操作)
|
||||||
|
*
|
||||||
|
* URL 状态:?subject=xxx &type=xxx &semester=xxx &q=xxx
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { Award } from "lucide-react";
|
import { Award, TrendingUp, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useTransition } from "react";
|
||||||
@@ -21,28 +30,51 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
import { useStudentGrades, type StudentGrade } from "@/lib/api";
|
import { useStudentGrades, type StudentGrade } from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import {
|
||||||
|
FilterBar,
|
||||||
|
FilterSearchInput,
|
||||||
|
} from "@/shared/components/ui/filter-bar";
|
||||||
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
|
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { RankingTrendCard } from "./components/ranking-trend-card";
|
||||||
|
import { ScoreDistributionCard } from "./components/score-distribution-card";
|
||||||
|
import { GrowthArchiveCard } from "./components/growth-archive-card";
|
||||||
|
|
||||||
/** 学科枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
/** 学科枚举键(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||||
const SUBJECT_OPTIONS = [
|
const SUBJECT_OPTION_KEYS = [
|
||||||
"语文",
|
"chinese",
|
||||||
"数学",
|
"math",
|
||||||
"英语",
|
"english",
|
||||||
"物理",
|
"physics",
|
||||||
"化学",
|
"chemistry",
|
||||||
"生物",
|
"biology",
|
||||||
"历史",
|
"history",
|
||||||
"地理",
|
"geography",
|
||||||
"政治",
|
"politics",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 成绩类型枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
/** 成绩类型枚举键(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||||
const TYPE_OPTIONS = ["考试", "作业", "测验", "综合"] as const;
|
const TYPE_OPTION_KEYS = ["exam", "homework", "quiz", "comprehensive"] as const;
|
||||||
|
|
||||||
|
/** 学期枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||||
|
const SEMESTER_OPTION_KEYS = ["1", "2"] as const;
|
||||||
|
|
||||||
|
/** 及格线得分率阈值(scoreRate >= 0.6 视为及格) */
|
||||||
|
const PASS_RATE_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** 优秀线得分率阈值(scoreRate >= 0.85 视为优秀) */
|
||||||
|
const EXCELLENT_RATE_THRESHOLD = 0.85;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成绩列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 成绩列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -57,6 +89,7 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
|
|
||||||
const subject = searchParams.get("subject") ?? "";
|
const subject = searchParams.get("subject") ?? "";
|
||||||
const type = searchParams.get("type") ?? "";
|
const type = searchParams.get("type") ?? "";
|
||||||
|
const semester = searchParams.get("semester") ?? "";
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
@@ -66,9 +99,26 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
q: q || undefined,
|
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[]>(() => {
|
const filteredItems = useMemo<StudentGrade[]>(() => {
|
||||||
return data ?? [];
|
if (!items) return [];
|
||||||
}, [data]);
|
if (!semester) return items;
|
||||||
|
return items.filter((g) => deriveSemester(g.date) === semester);
|
||||||
|
}, [items, semester]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => computeSummary(filteredItems), [filteredItems]);
|
||||||
|
const trendPoints = useMemo(
|
||||||
|
() => buildTrendPoints(filteredItems),
|
||||||
|
[filteredItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFilters = Boolean(subject || type || semester || q);
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
const updateQuery = (key: string, value: string): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -82,6 +132,12 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetFilters = (): void => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push("/shell/student/grades");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -91,7 +147,13 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const emptyNode = <EmptyState icon={Award} title={t("emptyTitle")} />;
|
const emptyNode = (
|
||||||
|
<EmptyState
|
||||||
|
icon={Award}
|
||||||
|
title={t("emptyTitle")}
|
||||||
|
description={t("emptyDescription")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
@@ -99,7 +161,7 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<Award className="size-6" />}
|
icon={<Award className="size-6" />}
|
||||||
filters={
|
filters={
|
||||||
<>
|
<FilterBar variant="wrap">
|
||||||
<FilterSearchInput
|
<FilterSearchInput
|
||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
value={q}
|
value={q}
|
||||||
@@ -112,9 +174,9 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
aria-label={t("subjectFilter")}
|
aria-label={t("subjectFilter")}
|
||||||
>
|
>
|
||||||
<option value="">{t("allSubjects")}</option>
|
<option value="">{t("allSubjects")}</option>
|
||||||
{SUBJECT_OPTIONS.map((s) => (
|
{SUBJECT_OPTION_KEYS.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{s}
|
{t(`subjects.${s}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -125,13 +187,37 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
aria-label={t("typeFilter")}
|
aria-label={t("typeFilter")}
|
||||||
>
|
>
|
||||||
<option value="">{t("allTypes")}</option>
|
<option value="">{t("allTypes")}</option>
|
||||||
{TYPE_OPTIONS.map((tp) => (
|
{TYPE_OPTION_KEYS.map((tp) => (
|
||||||
<option key={tp} value={tp}>
|
<option key={tp} value={tp}>
|
||||||
{tp}
|
{t(`types.${tp}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</>
|
<select
|
||||||
|
value={semester}
|
||||||
|
onChange={(e) => updateQuery("semester", e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
aria-label={t("semesterFilter")}
|
||||||
|
>
|
||||||
|
<option value="">{t("allSemesters")}</option>
|
||||||
|
{SEMESTER_OPTION_KEYS.map((sm) => (
|
||||||
|
<option key={sm} value={sm}>
|
||||||
|
{sm === "1" ? t("semester1") : t("semester2")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{hasFilters ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
{t("resetFilters")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</FilterBar>
|
||||||
}
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
@@ -144,11 +230,170 @@ export function StudentGradesListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<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>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 趋势图数据点。 */
|
||||||
|
interface TrendPoint {
|
||||||
|
date: string;
|
||||||
|
scoreRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成绩趋势卡(纯 SVG 折线图,展示得分率随时间变化)。
|
||||||
|
* 班级平均线 @contract-pending,目前仅绘制学生本人趋势。
|
||||||
|
*/
|
||||||
|
function TrendCard({ points }: { points: TrendPoint[] }): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.grades.list");
|
||||||
|
if (points.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TrendingUp className="size-4 text-muted-foreground" />
|
||||||
|
{t("sectionTrend")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex min-h-[160px] flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("trendEmptyTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("trendEmptyDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TrendingUp className="size-4 text-muted-foreground" />
|
||||||
|
{t("sectionTrend")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TrendChart points={points} />
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block size-2 rounded-full bg-primary" />
|
||||||
|
{t("trendScorePercent")}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 opacity-60">
|
||||||
|
<span className="inline-block size-2 rounded-full bg-muted-foreground" />
|
||||||
|
{t("trendClassAverage")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简易 SVG 折线图(无外部图表库依赖,对齐 §3.10 设计令牌规范)。 */
|
||||||
|
function TrendChart({ points }: { points: TrendPoint[] }): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.grades.list");
|
||||||
|
const width = 480;
|
||||||
|
const height = 160;
|
||||||
|
const padX = 32;
|
||||||
|
const padY = 16;
|
||||||
|
const innerW = width - padX * 2;
|
||||||
|
const innerH = height - padY * 2;
|
||||||
|
const n = points.length;
|
||||||
|
|
||||||
|
const xStep = n > 1 ? innerW / (n - 1) : 0;
|
||||||
|
const toX = (i: number): number => padX + i * xStep;
|
||||||
|
const toY = (rate: number): number => padY + innerH - rate * innerH;
|
||||||
|
|
||||||
|
const pathD =
|
||||||
|
n === 1
|
||||||
|
? `M ${toX(0)} ${toY(points[0]!.scoreRate)}`
|
||||||
|
: points
|
||||||
|
.map((p, i) => `${i === 0 ? "M" : "L"} ${toX(i)} ${toY(p.scoreRate)}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="h-40 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={t("sectionTrend")}
|
||||||
|
>
|
||||||
|
{/* 网格线 (0% / 50% / 100%) */}
|
||||||
|
{[0, 0.5, 1].map((g) => (
|
||||||
|
<line
|
||||||
|
key={g}
|
||||||
|
x1={padX}
|
||||||
|
x2={width - padX}
|
||||||
|
y1={toY(g)}
|
||||||
|
y2={toY(g)}
|
||||||
|
className="stroke-muted"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="2 4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 学生趋势折线 */}
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-primary"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* 数据点 */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={`${p.date}-${i}`}
|
||||||
|
cx={toX(i)}
|
||||||
|
cy={toY(p.scoreRate)}
|
||||||
|
r={3}
|
||||||
|
className="fill-primary"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生成绩表格(纯展示组件,对齐 §8.2 排版规范)。
|
* 学生成绩表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +459,53 @@ function StudentGradesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 成绩汇总统计。 */
|
||||||
|
interface GradeSummary {
|
||||||
|
total: number;
|
||||||
|
averageScoreRate: number;
|
||||||
|
passRate: number;
|
||||||
|
excellentRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从成绩列表计算汇总统计。 */
|
||||||
|
function computeSummary(items: StudentGrade[]): GradeSummary {
|
||||||
|
const total = items.length;
|
||||||
|
if (total === 0) {
|
||||||
|
return { total: 0, averageScoreRate: 0, passRate: 0, excellentRate: 0 };
|
||||||
|
}
|
||||||
|
const sumRate = items.reduce((sum, g) => sum + g.scoreRate, 0);
|
||||||
|
const passCount = items.filter(
|
||||||
|
(g) => g.scoreRate >= PASS_RATE_THRESHOLD,
|
||||||
|
).length;
|
||||||
|
const excellentCount = items.filter(
|
||||||
|
(g) => g.scoreRate >= EXCELLENT_RATE_THRESHOLD,
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
averageScoreRate: sumRate / total,
|
||||||
|
passRate: passCount / total,
|
||||||
|
excellentRate: excellentCount / total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建趋势图数据点(按日期升序)。 */
|
||||||
|
function buildTrendPoints(items: StudentGrade[]): TrendPoint[] {
|
||||||
|
return [...items]
|
||||||
|
.filter((g) => g.date && Number.isFinite(g.scoreRate))
|
||||||
|
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
|
.map((g) => ({ date: g.date, scoreRate: g.scoreRate }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 ISO 日期推导学期(9月-次年1月 = 第一学期,2-7月 = 第二学期)。 */
|
||||||
|
function deriveSemester(isoDate: string): string {
|
||||||
|
if (!isoDate) return "";
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
const month = d.getMonth() + 1;
|
||||||
|
if (month >= 9 || month <= 1) return "1";
|
||||||
|
return "2";
|
||||||
|
}
|
||||||
|
|
||||||
/** 格式化分数为展示字符串,保留 1 位小数。 */
|
/** 格式化分数为展示字符串,保留 1 位小数。 */
|
||||||
function formatScore(score: number): string {
|
function formatScore(score: number): string {
|
||||||
if (!Number.isFinite(score)) return "--";
|
if (!Number.isFinite(score)) return "--";
|
||||||
|
|||||||
@@ -4,37 +4,66 @@
|
|||||||
* 学生成绩报告卡页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
* 学生成绩报告卡页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - studentReportCard(academicYearId, semester):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentReportCard(academicYearId, semester) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-report-card
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-report-card
|
||||||
*
|
*
|
||||||
|
* 视图结构:
|
||||||
|
* 1. 学年/学期切换器(URL 状态)
|
||||||
|
* 2. 面包屑导航(返回成绩列表)
|
||||||
|
* 3. A4 报告卡容器(.report-card-a4,打印时仅此区域可见)
|
||||||
|
* - 学校抬头 / 学期信息
|
||||||
|
* - 学生基本信息区(姓名 / 班级 / 班主任 / 生成时间)
|
||||||
|
* - 各科成绩明细表(rowSpan 合并同学科单元格 + 学科排名 / 平均分)
|
||||||
|
* - 综合统计(总平均分 / 总排名 / 及格率 / 优秀率)
|
||||||
|
* - 教师评语
|
||||||
|
* - 签名区
|
||||||
|
* 4. 打印动作(window.print,含 loading 状态与错误兜底)
|
||||||
|
*
|
||||||
* URL 状态:?academicYearId=xxx &semester=xxx
|
* URL 状态:?academicYearId=xxx &semester=xxx
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 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
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { FileText } from "lucide-react";
|
import { FileText, Printer } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
StudentReportCard,
|
||||||
|
StudentReportCardRecord,
|
||||||
|
StudentReportCardSubject,
|
||||||
|
} from "@/lib/api";
|
||||||
import { useStudentReportCard } from "@/lib/api";
|
import { useStudentReportCard } from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||||
import {
|
import {
|
||||||
DetailPageShell,
|
DetailPageShell,
|
||||||
DetailPageSkeleton,
|
DetailPageSkeleton,
|
||||||
DetailSection,
|
DetailSection,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
/** 学年枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
/** 学年选项(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||||
const ACADEMIC_YEAR_OPTIONS = ["2024-2025", "2025-2026"] as const;
|
const ACADEMIC_YEAR_OPTIONS = ["2024-2025", "2025-2026"] as const;
|
||||||
|
|
||||||
/** 学期枚举(@contract-pending,待后端契约补齐后改为动态拉取) */
|
/** 学期选项(@contract-pending,待后端契约补齐后改为动态拉取) */
|
||||||
const SEMESTER_OPTIONS = ["1", "2"] as const;
|
const SEMESTER_OPTIONS = ["1", "2"] as const;
|
||||||
|
|
||||||
|
/** 及格线得分率阈值(scoreRate >= 0.6 视为及格) */
|
||||||
|
const PASS_RATE_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** 优秀线得分率阈值(scoreRate >= 0.85 视为优秀) */
|
||||||
|
const EXCELLENT_RATE_THRESHOLD = 0.85;
|
||||||
|
|
||||||
|
/** 打印前 loading 延迟(ms),让 UI 先渲染"准备中…" */
|
||||||
|
const PRINT_LOADING_DELAY_MS = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报告卡客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 成绩报告卡客户端主体。需用 server page 包裹在 <Suspense> 中
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
*/
|
*/
|
||||||
export function StudentReportCardClient(): React.ReactElement {
|
export function StudentReportCardClient(): React.ReactElement {
|
||||||
@@ -43,6 +72,7 @@ export function StudentReportCardClient(): React.ReactElement {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
|
||||||
const academicYearId = searchParams.get("academicYearId") ?? "";
|
const academicYearId = searchParams.get("academicYearId") ?? "";
|
||||||
const semester = searchParams.get("semester") ?? "";
|
const semester = searchParams.get("semester") ?? "";
|
||||||
@@ -53,6 +83,8 @@ export function StudentReportCardClient(): React.ReactElement {
|
|||||||
semester: semester || undefined,
|
semester: semester || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const summary = useMemo(() => computeSummary(data?.subjects ?? []), [data]);
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
const updateQuery = (key: string, value: string): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -65,24 +97,84 @@ export function StudentReportCardClient(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrint = (): void => {
|
const handlePrint = useCallback((): void => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window === "undefined") return;
|
||||||
window.print();
|
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 ? (
|
if (loading) {
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
return <DetailPageSkeleton />;
|
||||||
<p className="text-sm text-destructive">
|
}
|
||||||
{tCommon("error.loadFailed", { message: String(error) })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : undefined;
|
|
||||||
|
|
||||||
const emptyNode =
|
if (error) {
|
||||||
!loading && !error && !data ? (
|
return (
|
||||||
<EmptyState icon={FileText} title={t("emptyTitle")} />
|
<DetailPageShell
|
||||||
) : undefined;
|
title={t("title")}
|
||||||
|
description={t("description")}
|
||||||
|
icon={<FileText className="size-6" />}
|
||||||
|
backHref="/shell/student/grades"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title={tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
description={t("emptyDescription")}
|
||||||
|
/>
|
||||||
|
</DetailPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.subjects.length === 0) {
|
||||||
|
return (
|
||||||
|
<DetailPageShell
|
||||||
|
title={t("title")}
|
||||||
|
description={t("description")}
|
||||||
|
icon={<FileText className="size-6" />}
|
||||||
|
backHref="/shell/student/grades"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={handlePrint}>
|
||||||
|
<Printer className="mr-2 size-4" />
|
||||||
|
{t("print")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title={t("emptyTitle")}
|
||||||
|
description={t("emptyDescription")}
|
||||||
|
/>
|
||||||
|
</DetailPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const semesterLabel = semester ? t(`semester${semester}`) : t("allSemesters");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPageShell
|
<DetailPageShell
|
||||||
@@ -91,223 +183,555 @@ export function StudentReportCardClient(): React.ReactElement {
|
|||||||
icon={<FileText className="size-6" />}
|
icon={<FileText className="size-6" />}
|
||||||
backHref="/shell/student/grades"
|
backHref="/shell/student/grades"
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={handlePrint} disabled={!data}>
|
<Button variant="outline" onClick={handlePrint} disabled={isPrinting}>
|
||||||
{t("print")}
|
<Printer className="mr-2 size-4" />
|
||||||
|
{isPrinting ? t("preparing") : t("print")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
loadingNode={<DetailPageSkeleton />}
|
|
||||||
errorNode={errorNode}
|
|
||||||
emptyNode={emptyNode}
|
|
||||||
>
|
>
|
||||||
{data ? <ReportCardBody data={data} /> : null}
|
{/* 学年/学期切换器(打印时隐藏) */}
|
||||||
<ReportCardFilters
|
<DetailSection title={t("periodSelectorTitle")}>
|
||||||
academicYearId={academicYearId}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
semester={semester}
|
<label className="text-sm text-muted-foreground">
|
||||||
onAcademicYearChange={(v) => updateQuery("academicYearId", v)}
|
{t("academicYearLabel")}
|
||||||
onSemesterChange={(v) => updateQuery("semester", v)}
|
</label>
|
||||||
|
<select
|
||||||
|
value={academicYearId}
|
||||||
|
onChange={(e) => updateQuery("academicYearId", e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
aria-label={t("academicYearLabel")}
|
||||||
|
>
|
||||||
|
<option value="">{t("allAcademicYears")}</option>
|
||||||
|
{ACADEMIC_YEAR_OPTIONS.map((year) => (
|
||||||
|
<option key={year} value={year}>
|
||||||
|
{year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="text-sm text-muted-foreground">
|
||||||
|
{t("semesterLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={semester}
|
||||||
|
onChange={(e) => updateQuery("semester", e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
aria-label={t("semesterLabel")}
|
||||||
|
>
|
||||||
|
<option value="">{t("allSemesters")}</option>
|
||||||
|
{SEMESTER_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`semester${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||||
|
{t("resetFilters")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
{/* A4 报告卡主体(打印时仅此区域可见) */}
|
||||||
|
<ReportCardA4
|
||||||
|
data={data}
|
||||||
|
academicYear={data.academicYear || academicYearId}
|
||||||
|
semester={semesterLabel}
|
||||||
|
summary={summary}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 综合统计(屏幕视图,打印时随 A4 容器内汇总区呈现) */}
|
||||||
|
<DetailSection title={t("summarySectionTitle")}>
|
||||||
|
<StatsGrid columns={4}>
|
||||||
|
<SummaryStat
|
||||||
|
label={t("overallAverage")}
|
||||||
|
value={summary.averageScore.toFixed(1)}
|
||||||
|
/>
|
||||||
|
<SummaryStat
|
||||||
|
label={t("overallRank")}
|
||||||
|
value={
|
||||||
|
data.overallRank != null && data.classTotalStudents != null
|
||||||
|
? t("rankFormat", {
|
||||||
|
rank: data.overallRank,
|
||||||
|
total: data.classTotalStudents,
|
||||||
|
})
|
||||||
|
: "--"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryStat
|
||||||
|
label={t("passRate")}
|
||||||
|
value={formatPercent(summary.passRate)}
|
||||||
|
/>
|
||||||
|
<SummaryStat
|
||||||
|
label={t("excellentRate")}
|
||||||
|
value={formatPercent(summary.excellentRate)}
|
||||||
|
/>
|
||||||
|
</StatsGrid>
|
||||||
|
</DetailSection>
|
||||||
</DetailPageShell>
|
</DetailPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A4 报告卡容器:学校抬头 + 学生信息 + 学科明细 + 汇总 + 评语 + 签名。 */
|
||||||
* 报告卡筛选器(学年/学期切换)。
|
function ReportCardA4({
|
||||||
*/
|
|
||||||
function ReportCardFilters({
|
|
||||||
academicYearId,
|
|
||||||
semester,
|
|
||||||
onAcademicYearChange,
|
|
||||||
onSemesterChange,
|
|
||||||
}: {
|
|
||||||
academicYearId: string;
|
|
||||||
semester: string;
|
|
||||||
onAcademicYearChange: (v: string) => void;
|
|
||||||
onSemesterChange: (v: string) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const t = useTranslations("studentDomain.grades.reportCard");
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 sm:flex-row sm:items-center sm:gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="academic-year-select"
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("academicYearLabel")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="academic-year-select"
|
|
||||||
value={academicYearId}
|
|
||||||
onChange={(e) => onAcademicYearChange(e.target.value)}
|
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">{t("academicYearLabel")}</option>
|
|
||||||
{ACADEMIC_YEAR_OPTIONS.map((y) => (
|
|
||||||
<option key={y} value={y}>
|
|
||||||
{y}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="semester-select"
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("semesterLabel")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="semester-select"
|
|
||||||
value={semester}
|
|
||||||
onChange={(e) => onSemesterChange(e.target.value)}
|
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">{t("semesterLabel")}</option>
|
|
||||||
{SEMESTER_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 报告卡内容区(成绩汇总表)。
|
|
||||||
*/
|
|
||||||
function ReportCardBody({
|
|
||||||
data,
|
data,
|
||||||
}: {
|
|
||||||
data: NonNullable<ReturnType<typeof useStudentReportCard>["data"]>;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const t = useTranslations("studentDomain.grades.reportCard");
|
|
||||||
return (
|
|
||||||
<DetailSection title={t("sectionSummary")}>
|
|
||||||
<ReportCardTable
|
|
||||||
subjects={data.subjects}
|
|
||||||
academicYear={data.academicYear}
|
|
||||||
semester={data.semester}
|
|
||||||
/>
|
|
||||||
</DetailSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 成绩汇总表格。
|
|
||||||
*/
|
|
||||||
function ReportCardTable({
|
|
||||||
subjects,
|
|
||||||
academicYear,
|
academicYear,
|
||||||
semester,
|
semester,
|
||||||
|
summary,
|
||||||
}: {
|
}: {
|
||||||
subjects: NonNullable<
|
data: StudentReportCard;
|
||||||
ReturnType<typeof useStudentReportCard>["data"]
|
|
||||||
>["subjects"];
|
|
||||||
academicYear: string;
|
academicYear: string;
|
||||||
semester: string;
|
semester: string;
|
||||||
|
summary: ReportCardSummary;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.grades.reportCard");
|
const t = useTranslations("studentDomain.grades.reportCard");
|
||||||
if (subjects.length === 0) {
|
|
||||||
return <p className="text-sm text-muted-foreground">{t("emptyTitle")}</p>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="report-card-a4 mx-auto w-full max-w-3xl rounded-lg border-2 bg-white p-8 shadow-sm">
|
||||||
<table className="w-full text-sm">
|
{/* 学校抬头 */}
|
||||||
<thead>
|
<header className="border-b-2 border-black pb-3 text-center">
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<h1 className="text-2xl font-bold">{t("reportCardTitle")}</h1>
|
||||||
<th className="py-2 pr-4 font-medium">{t("colSubject")}</th>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
<th className="py-2 pr-4 font-medium">{t("colScore")}</th>
|
{t("periodLabel", { year: academicYear || "--", semester })}
|
||||||
<th className="py-2 pr-4 font-medium">{t("colTotalScore")}</th>
|
</p>
|
||||||
<th className="py-2 pr-4 font-medium">{t("colScoreRate")}</th>
|
</header>
|
||||||
<th className="py-2 pr-4 font-medium">{t("colLevel")}</th>
|
|
||||||
<th className="py-2 pr-4 font-medium">{t("colTeacherComment")}</th>
|
{/* 学生基本信息 */}
|
||||||
</tr>
|
<section className="report-card-student-info mt-4">
|
||||||
</thead>
|
<h2 className="mb-2 text-base font-semibold">
|
||||||
<tbody>
|
{t("studentInfoTitle")}
|
||||||
{subjects.map((entry, idx) => (
|
</h2>
|
||||||
<tr
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2 rounded-md border bg-muted/30 p-3 text-sm sm:grid-cols-4">
|
||||||
key={`${entry.subject}-${idx}`}
|
<InfoField
|
||||||
className="border-b last:border-0"
|
label={t("studentNameLabel")}
|
||||||
>
|
value={data.studentName ?? "--"}
|
||||||
<td className="py-2 pr-4 font-medium">{entry.subject}</td>
|
/>
|
||||||
<td className="py-2 pr-4">
|
<InfoField
|
||||||
<span className="font-semibold">
|
label={t("classNameLabel")}
|
||||||
{formatScore(entry.score)}
|
value={data.className ?? "--"}
|
||||||
</span>
|
/>
|
||||||
</td>
|
<InfoField
|
||||||
<td className="py-2 pr-4 text-muted-foreground">
|
label={t("classTeacherLabel")}
|
||||||
{formatScore(entry.totalScore)}
|
value={data.classTeacherName ?? "--"}
|
||||||
</td>
|
/>
|
||||||
<td className="py-2 pr-4">
|
<InfoField
|
||||||
<span className={scoreRateToColorClass(entry.scoreRate)}>
|
label={t("generatedAtLabel")}
|
||||||
{formatRate(entry.scoreRate)}
|
value={formatDateTime(data.generatedAt)}
|
||||||
</span>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td className="py-2 pr-4">
|
</section>
|
||||||
<span className={levelToColorClass(entry.level)}>
|
|
||||||
{entry.level}
|
{/* 各科成绩明细(rowSpan 合并同学科单元格) */}
|
||||||
</span>
|
<section className="report-card-grades mt-4">
|
||||||
</td>
|
<h2 className="mb-2 text-base font-semibold">
|
||||||
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
{t("gradesSectionTitle")}
|
||||||
{entry.teacherComment || "--"}
|
</h2>
|
||||||
</td>
|
<GradesTable subjects={data.subjects} />
|
||||||
</tr>
|
</section>
|
||||||
))}
|
|
||||||
</tbody>
|
{/* 综合统计(A4 内汇总区,打印时不被分页切割) */}
|
||||||
{academicYear || semester ? (
|
<section className="report-card-summary mt-4 rounded-md border bg-muted/20 p-3">
|
||||||
<tfoot>
|
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||||
<tr className="border-t text-xs text-muted-foreground">
|
<SummaryStat
|
||||||
<td className="py-2 pr-4" colSpan={6}>
|
label={t("overallAverage")}
|
||||||
{academicYear
|
value={summary.averageScore.toFixed(1)}
|
||||||
? `${t("academicYearLabel")}: ${academicYear}`
|
/>
|
||||||
: ""}
|
<SummaryStat
|
||||||
{academicYear && semester ? " / " : ""}
|
label={t("overallRank")}
|
||||||
{semester ? `${t("semesterLabel")}: ${semester}` : ""}
|
value={
|
||||||
</td>
|
data.overallRank != null && data.classTotalStudents != null
|
||||||
</tr>
|
? t("rankFormat", {
|
||||||
</tfoot>
|
rank: data.overallRank,
|
||||||
) : null}
|
total: data.classTotalStudents,
|
||||||
</table>
|
})
|
||||||
|
: "--"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化分数为展示字符串,保留 1 位小数。 */
|
/** 学科成绩明细表:使用 rowSpan 合并同学科单元格,展示多记录展开。 */
|
||||||
function formatScore(score: number): string {
|
function GradesTable({
|
||||||
if (!Number.isFinite(score)) return "--";
|
subjects,
|
||||||
return score.toFixed(1);
|
}: {
|
||||||
|
subjects: StudentReportCardSubject[];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.grades.reportCard");
|
||||||
|
return (
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/20">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left font-medium text-muted-foreground">
|
||||||
|
{t("colSubject")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium text-muted-foreground">
|
||||||
|
{t("colAssessment")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-center font-medium text-muted-foreground">
|
||||||
|
{t("colType")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-right font-medium text-muted-foreground">
|
||||||
|
{t("colScore")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-right font-medium text-muted-foreground">
|
||||||
|
{t("colTotalScore")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-left font-medium text-muted-foreground">
|
||||||
|
{t("colRemark")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-center font-medium text-muted-foreground">
|
||||||
|
{t("colLevel")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-center font-medium text-muted-foreground">
|
||||||
|
{t("colRank")}
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-right font-medium text-muted-foreground">
|
||||||
|
{t("subjectAvgLabel")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{subjects.flatMap((subj) =>
|
||||||
|
expandSubjectRows(subj).map((row, idx) => (
|
||||||
|
<SubjectRow key={`${subj.subject}-${idx}`} row={row} />
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化 0-1 的得分率为百分比字符串。 */
|
/** 单行渲染:根据 rowSpan 标记决定是否渲染学科汇总单元格。 */
|
||||||
function formatRate(rate: number): string {
|
function SubjectRow({ row }: { row: SubjectTableRow }): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.grades.reportCard");
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-muted/20">
|
||||||
|
{row.subjectCell != null ? (
|
||||||
|
<td
|
||||||
|
className="border-r p-2 align-middle font-medium"
|
||||||
|
rowSpan={row.subjectCell.rowSpan}
|
||||||
|
>
|
||||||
|
{row.subjectCell.subject}
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("colScore")}:{row.subjectCell.score.toFixed(1)} /{" "}
|
||||||
|
{row.subjectCell.totalScore.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("colScoreRate")}:{formatPercent(row.subjectCell.scoreRate)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
<td className="p-2 align-middle">{row.record?.title ?? "--"}</td>
|
||||||
|
<td className="p-2 text-center align-middle">
|
||||||
|
{row.record ? translateRecordType(row.record.type, t) : "--"}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right align-middle tabular-nums">
|
||||||
|
{row.record ? row.record.score.toFixed(1) : "--"}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right align-middle tabular-nums text-muted-foreground">
|
||||||
|
{row.record ? row.record.totalScore.toFixed(1) : "--"}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 align-middle text-muted-foreground">
|
||||||
|
{row.record?.remark || "--"}
|
||||||
|
</td>
|
||||||
|
{row.subjectCell != null ? (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
className="p-2 text-center align-middle"
|
||||||
|
rowSpan={row.subjectCell.rowSpan}
|
||||||
|
>
|
||||||
|
<LevelBadge level={row.subjectCell.level} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="p-2 text-center align-middle tabular-nums"
|
||||||
|
rowSpan={row.subjectCell.rowSpan}
|
||||||
|
>
|
||||||
|
{formatRank(
|
||||||
|
row.subjectCell.rankInSubject,
|
||||||
|
row.subjectCell.totalStudentsInSubject,
|
||||||
|
t,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="p-2 text-right align-middle tabular-nums text-muted-foreground"
|
||||||
|
rowSpan={row.subjectCell.rowSpan}
|
||||||
|
>
|
||||||
|
{row.subjectCell.subjectAvg != null
|
||||||
|
? row.subjectCell.subjectAvg.toFixed(1)
|
||||||
|
: "--"}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 综合统计中的单格。 */
|
||||||
|
function SummaryStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-card p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold tabular-nums">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 信息字段(学生基本信息区)。 */
|
||||||
|
function InfoField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级徽章。 */
|
||||||
|
function LevelBadge({ level }: { level: string }): React.ReactElement {
|
||||||
|
const cls = levelToBadgeClass(level);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||||
|
>
|
||||||
|
{level || "--"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 签名区块。 */
|
||||||
|
function SignatureBlock({ label }: { label: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<div className="report-card-signature-line h-10 rounded-md border border-dashed" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报告卡综合统计。 */
|
||||||
|
interface ReportCardSummary {
|
||||||
|
subjectCount: number;
|
||||||
|
totalScore: number;
|
||||||
|
fullScore: number;
|
||||||
|
averageRate: number;
|
||||||
|
averageScore: number;
|
||||||
|
passRate: number;
|
||||||
|
excellentRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学科单元格(用于 rowSpan 合并)。 */
|
||||||
|
interface SubjectCell {
|
||||||
|
subject: string;
|
||||||
|
score: number;
|
||||||
|
totalScore: number;
|
||||||
|
scoreRate: number;
|
||||||
|
level: string;
|
||||||
|
rankInSubject?: number;
|
||||||
|
totalStudentsInSubject?: number;
|
||||||
|
subjectAvg?: number;
|
||||||
|
rowSpan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格行(学科首行携带 subjectCell,后续行 subjectCell 为 null)。 */
|
||||||
|
interface SubjectTableRow {
|
||||||
|
subjectCell: SubjectCell | null;
|
||||||
|
record: StudentReportCardRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将学科展开为多行(含 rowSpan 信息)。无记录时降级为单行空记录。 */
|
||||||
|
function expandSubjectRows(
|
||||||
|
subject: StudentReportCardSubject,
|
||||||
|
): SubjectTableRow[] {
|
||||||
|
const records = subject.records ?? [];
|
||||||
|
const rowSpan = records.length > 0 ? records.length : 1;
|
||||||
|
const subjectCell: SubjectCell = {
|
||||||
|
subject: subject.subject,
|
||||||
|
score: subject.score,
|
||||||
|
totalScore: subject.totalScore,
|
||||||
|
scoreRate: subject.scoreRate,
|
||||||
|
level: subject.level,
|
||||||
|
rankInSubject: subject.rankInSubject,
|
||||||
|
totalStudentsInSubject: subject.totalStudentsInSubject,
|
||||||
|
subjectAvg: subject.subjectAvg,
|
||||||
|
rowSpan,
|
||||||
|
};
|
||||||
|
if (records.length === 0) {
|
||||||
|
return [{ subjectCell, record: null }];
|
||||||
|
}
|
||||||
|
return records.map((record, idx) => ({
|
||||||
|
subjectCell: idx === 0 ? subjectCell : null,
|
||||||
|
record,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算报告卡综合统计。 */
|
||||||
|
function computeSummary(
|
||||||
|
subjects: StudentReportCardSubject[],
|
||||||
|
): ReportCardSummary {
|
||||||
|
if (subjects.length === 0) {
|
||||||
|
return {
|
||||||
|
subjectCount: 0,
|
||||||
|
totalScore: 0,
|
||||||
|
fullScore: 0,
|
||||||
|
averageRate: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
passRate: 0,
|
||||||
|
excellentRate: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const totalScore = subjects.reduce((acc, s) => acc + s.score, 0);
|
||||||
|
const fullScore = subjects.reduce((acc, s) => acc + s.totalScore, 0);
|
||||||
|
const averageRate = fullScore > 0 ? totalScore / fullScore : 0;
|
||||||
|
const averageScore =
|
||||||
|
subjects.reduce((acc, s) => acc + s.scoreRate, 0) / subjects.length;
|
||||||
|
const passCount = subjects.filter(
|
||||||
|
(s) => s.scoreRate >= PASS_RATE_THRESHOLD,
|
||||||
|
).length;
|
||||||
|
const excellentCount = subjects.filter(
|
||||||
|
(s) => s.scoreRate >= EXCELLENT_RATE_THRESHOLD,
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
subjectCount: subjects.length,
|
||||||
|
totalScore,
|
||||||
|
fullScore,
|
||||||
|
averageRate,
|
||||||
|
averageScore,
|
||||||
|
passRate: passCount / subjects.length,
|
||||||
|
excellentRate: excellentCount / subjects.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据等级返回 Tailwind 徽章类名。 */
|
||||||
|
function levelToBadgeClass(level: string): string {
|
||||||
|
const normalized = level.trim();
|
||||||
|
if (
|
||||||
|
normalized === "优秀" ||
|
||||||
|
normalized === "A" ||
|
||||||
|
normalized === "Excellent"
|
||||||
|
) {
|
||||||
|
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||||
|
}
|
||||||
|
if (normalized === "良好" || normalized === "B" || normalized === "Good") {
|
||||||
|
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||||
|
}
|
||||||
|
if (normalized === "合格" || normalized === "C" || normalized === "Pass") {
|
||||||
|
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "不合格" ||
|
||||||
|
normalized === "D" ||
|
||||||
|
normalized === "F" ||
|
||||||
|
normalized === "Fail"
|
||||||
|
) {
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
}
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化 0-1 的比率为百分比字符串。 */
|
||||||
|
function formatPercent(rate: number): string {
|
||||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||||
return `${(rate * 100).toFixed(0)}%`;
|
return `${(rate * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据得分率返回 Tailwind 文本语义类名。 */
|
/** 格式化排名为 "{rank} / {total}"。 */
|
||||||
function scoreRateToColorClass(rate: number): string {
|
function formatRank(
|
||||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) {
|
rank: number | undefined,
|
||||||
return "text-muted-foreground";
|
total: number | undefined,
|
||||||
}
|
t: ReturnType<typeof useTranslations>,
|
||||||
if (rate >= 0.8) return "text-emerald-600";
|
): string {
|
||||||
if (rate >= 0.6) return "text-amber-600";
|
if (rank == null || total == null) return "--";
|
||||||
return "text-destructive";
|
return t("rankFormat", { rank, total });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据等级返回 Tailwind 文本语义类名。 */
|
/** 将记录类型映射为 i18n 文案。 */
|
||||||
function levelToColorClass(level: string): string {
|
function translateRecordType(
|
||||||
switch (level) {
|
type: string,
|
||||||
case "A":
|
t: ReturnType<typeof useTranslations>,
|
||||||
return "text-emerald-600";
|
): string {
|
||||||
case "B":
|
switch (type) {
|
||||||
return "text-blue-600";
|
case "exam":
|
||||||
case "C":
|
return t("typeExam");
|
||||||
return "text-amber-600";
|
case "homework":
|
||||||
case "D":
|
return t("typeHomework");
|
||||||
return "text-destructive";
|
case "quiz":
|
||||||
|
return t("typeQuiz");
|
||||||
default:
|
default:
|
||||||
return "text-muted-foreground";
|
return type || "--";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 格式化 ISO 日期为本地化日期时间。 */
|
||||||
|
function formatDateTime(isoDate: string | undefined): string {
|
||||||
|
if (!isoDate) return "--";
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (Number.isNaN(d.getTime())) return "--";
|
||||||
|
return d.toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,22 +7,39 @@
|
|||||||
* - studentHomework(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentHomework(status) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
*
|
*
|
||||||
* URL 状态:?status=xxx
|
* URL 状态:?status=xxx
|
||||||
|
* 本地状态:viewMode(card/table)、searchQ(搜索词)
|
||||||
|
*
|
||||||
|
* 视图:
|
||||||
|
* - 卡片视图:网格卡片,展示标题/学科/截止/状态/尝试次数/最新成绩/操作
|
||||||
|
* - 表格视图:保留原学科分组表格
|
||||||
|
*
|
||||||
|
* 子区域:每个学科下分"未答/已答"两组
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { BookOpen } from "lucide-react";
|
import {
|
||||||
|
BookOpen,
|
||||||
|
LayoutGrid,
|
||||||
|
SearchX,
|
||||||
|
Table as TableIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useStudentHomework, type StudentHomework } from "@/lib/api";
|
import { useStudentHomework, type StudentHomework } from "@/lib/api";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
type ViewMode = "card" | "table";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -36,20 +53,25 @@ export function StudentHomeworkListClient(): React.ReactElement {
|
|||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const statusFilter = searchParams.get("status") ?? "";
|
const statusFilter = searchParams.get("status") ?? "";
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
||||||
|
const [searchQ, setSearchQ] = useState<string>("");
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useStudentHomework({
|
const { data, loading, error } = useStudentHomework({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 客户端二次筛选(status)—— 后端补齐契约后改服务端筛选
|
// 客户端二次筛选(status + search)—— 后端补齐契约后改服务端筛选
|
||||||
const filteredItems = useMemo<StudentHomework[]>(() => {
|
const filteredItems = useMemo<StudentHomework[]>(() => {
|
||||||
const items = data ?? [];
|
const items = data ?? [];
|
||||||
if (!statusFilter) return items;
|
const q = searchQ.trim().toLowerCase();
|
||||||
return items.filter(
|
return items.filter((item) => {
|
||||||
(item) => normalizeStatus(item.status) === statusFilter,
|
const matchStatus =
|
||||||
);
|
!statusFilter || normalizeStatus(item.status) === statusFilter;
|
||||||
}, [data, statusFilter]);
|
const matchSearch = !q || item.title.toLowerCase().includes(q);
|
||||||
|
return matchStatus && matchSearch;
|
||||||
|
});
|
||||||
|
}, [data, statusFilter, searchQ]);
|
||||||
|
|
||||||
// 按学科分组
|
// 按学科分组
|
||||||
const grouped = useMemo<Map<string, StudentHomework[]>>(() => {
|
const grouped = useMemo<Map<string, StudentHomework[]>>(() => {
|
||||||
@@ -83,31 +105,47 @@ export function StudentHomeworkListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : 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 (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<BookOpen className="size-6" />}
|
icon={<BookOpen className="size-6" />}
|
||||||
filters={
|
filters={filtersNode}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
empty={filteredItems.length === 0 && !loading}
|
empty={showEmpty && !searchQ && !statusFilter}
|
||||||
emptyNode={
|
emptyNode={
|
||||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-2 p-8 text-center">
|
<EmptyState
|
||||||
<p className="text-lg font-semibold">{t("emptyTitle")}</p>
|
icon={BookOpen}
|
||||||
</div>
|
title={t("emptyTitle")}
|
||||||
|
description={t("emptyDesc")}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
@@ -116,87 +154,349 @@ export function StudentHomeworkListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
{showEmpty && (searchQ || statusFilter) ? (
|
||||||
{Array.from(grouped.entries()).map(([subject, items]) => (
|
<EmptyState
|
||||||
<HomeworkSubjectGroup key={subject} subject={subject} items={items} />
|
icon={SearchX}
|
||||||
))}
|
title={t("noResults")}
|
||||||
</div>
|
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>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学科分组(标题 + 表格)。
|
* 视图切换器(卡片 / 表格)。
|
||||||
|
*/
|
||||||
|
function ViewModeToggle({
|
||||||
|
viewMode,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onChange: (mode: ViewMode) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.homework.list");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex h-9 items-center rounded-lg border border-input bg-muted p-1"
|
||||||
|
role="group"
|
||||||
|
aria-label="view mode"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("card")}
|
||||||
|
aria-pressed={viewMode === "card"}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
|
||||||
|
viewMode === "card"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-3.5" />
|
||||||
|
{t("viewCard")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("table")}
|
||||||
|
aria-pressed={viewMode === "table"}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium transition-colors",
|
||||||
|
viewMode === "table"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TableIcon className="size-3.5" />
|
||||||
|
{t("viewTable")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学科分组:标题 + 未答/已答子区域。
|
||||||
*/
|
*/
|
||||||
function HomeworkSubjectGroup({
|
function HomeworkSubjectGroup({
|
||||||
subject,
|
subject,
|
||||||
items,
|
items,
|
||||||
|
viewMode,
|
||||||
}: {
|
}: {
|
||||||
subject: string;
|
subject: string;
|
||||||
items: StudentHomework[];
|
items: StudentHomework[];
|
||||||
|
viewMode: ViewMode;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.homework.list");
|
const t = useTranslations("studentDomain.homework.list");
|
||||||
|
|
||||||
|
// 按答题状态分两组:未答 / 已答
|
||||||
|
const { unanswered, answered } = useMemo(() => {
|
||||||
|
const ua: StudentHomework[] = [];
|
||||||
|
const ad: StudentHomework[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const normalized = normalizeStatus(item.status);
|
||||||
|
if (normalized === "submitted" || normalized === "graded") {
|
||||||
|
ad.push(item);
|
||||||
|
} else {
|
||||||
|
ua.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { unanswered: ua, answered: ad };
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border bg-card">
|
<section className="rounded-xl border bg-card">
|
||||||
<header className="border-b bg-muted/30 px-4 py-3">
|
<header className="border-b bg-muted/30 px-4 py-3">
|
||||||
<h2 className="text-sm font-semibold">{subject}</h2>
|
<h2 className="text-sm font-semibold">{subject}</h2>
|
||||||
</header>
|
</header>
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-4 p-4">
|
||||||
<table className="w-full text-sm">
|
{unanswered.length > 0 ? (
|
||||||
<thead className="border-b text-left text-muted-foreground">
|
<HomeworkSubGroup
|
||||||
<tr>
|
label={t("groupUnanswered")}
|
||||||
<th className="p-3 font-medium">{t("colTitle")}</th>
|
items={unanswered}
|
||||||
<th className="p-3 font-medium">{t("colDueDate")}</th>
|
viewMode={viewMode}
|
||||||
<th className="p-3 font-medium">{t("colStatus")}</th>
|
/>
|
||||||
<th className="p-3 text-right font-medium">{t("colScore")}</th>
|
) : null}
|
||||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
{answered.length > 0 ? (
|
||||||
</tr>
|
<HomeworkSubGroup
|
||||||
</thead>
|
label={t("groupAnswered")}
|
||||||
<tbody className="divide-y">
|
items={answered}
|
||||||
{items.map((hw) => (
|
viewMode={viewMode}
|
||||||
<tr key={hw.id} className="hover:bg-muted/30">
|
/>
|
||||||
<td className="p-3">
|
) : null}
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</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;
|
status: string;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const cls = homeworkStatusToBadgeClass(status);
|
const t = useTranslations("studentDomain.homework.list");
|
||||||
|
const normalized = normalizeStatus(status);
|
||||||
|
const cls = homeworkStatusToBadgeClass(normalized);
|
||||||
|
const label = homeworkStatusLabel(normalized, status, t);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||||
>
|
>
|
||||||
{status}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态归一化(兼容 MSW 中文与未来英文枚举)。
|
* 状态归一化(兼容 MSW 中文与未来英文枚举)。
|
||||||
* pending / submitted / graded / overdue
|
* pending / submitted / graded / overdue / in_progress / not_started
|
||||||
*/
|
*/
|
||||||
function normalizeStatus(status: string): string {
|
function normalizeStatus(status: string): string {
|
||||||
const s = status.toLowerCase();
|
const s = status.toLowerCase();
|
||||||
if (s.includes("pend") || s.includes("待提交")) return "pending";
|
if (s.includes("overdue") || s.includes("逾期")) return "overdue";
|
||||||
if (s.includes("submit") || s.includes("已提交")) return "submitted";
|
|
||||||
if (s.includes("grad") || s.includes("已完成") || s.includes("已批改")) {
|
if (s.includes("grad") || s.includes("已完成") || s.includes("已批改")) {
|
||||||
return "graded";
|
return "graded";
|
||||||
}
|
}
|
||||||
if (s.includes("overdue") || s.includes("逾期")) return "overdue";
|
if (s.includes("submit") || s.includes("已提交")) return "submitted";
|
||||||
|
if (s.includes("in_progress") || s.includes("进行中")) return "in_progress";
|
||||||
|
if (s.includes("not_started") || s.includes("未开始")) return "not_started";
|
||||||
|
if (s.includes("pend") || s.includes("待提交")) return "pending";
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。
|
* 状态 → 徽章色阶(语义化 Tailwind 类,无硬编码颜色)。
|
||||||
*/
|
*/
|
||||||
function homeworkStatusToBadgeClass(status: string): string {
|
function homeworkStatusToBadgeClass(normalized: string): string {
|
||||||
const normalized = normalizeStatus(status);
|
|
||||||
switch (normalized) {
|
switch (normalized) {
|
||||||
|
case "not_started":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
case "pending":
|
case "pending":
|
||||||
return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200";
|
return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200";
|
||||||
|
case "in_progress":
|
||||||
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
|
||||||
case "submitted":
|
case "submitted":
|
||||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
|
||||||
case "graded":
|
case "graded":
|
||||||
@@ -248,3 +556,29 @@ function homeworkStatusToBadgeClass(status: string): string {
|
|||||||
return "bg-muted text-muted-foreground";
|
return "bg-muted text-muted-foreground";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → 本地化标签。
|
||||||
|
*/
|
||||||
|
function homeworkStatusLabel(
|
||||||
|
normalized: string,
|
||||||
|
raw: string,
|
||||||
|
t: ReturnType<typeof useTranslations>,
|
||||||
|
): string {
|
||||||
|
switch (normalized) {
|
||||||
|
case "not_started":
|
||||||
|
return t("statusNotStarted");
|
||||||
|
case "pending":
|
||||||
|
return t("statusPending");
|
||||||
|
case "in_progress":
|
||||||
|
return t("statusInProgress");
|
||||||
|
case "submitted":
|
||||||
|
return t("statusSubmitted");
|
||||||
|
case "graded":
|
||||||
|
return t("statusGraded");
|
||||||
|
case "overdue":
|
||||||
|
return t("statusOverdue");
|
||||||
|
default:
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
* 学生作业作答工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
* 学生作业作答工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约(@contract-pending 全 MSW):
|
* 数据契约(@contract-pending 全 MSW):
|
||||||
* - studentHomeworkSubmit(id) ❌ → MSW 兜底
|
* - studentHomeworkSubmit(id) -> MSW 兜底
|
||||||
* - submitStudentHomework(input) mutation ❌ → MSW 兜底
|
* - submitStudentHomework(input) mutation -> MSW 兜底
|
||||||
|
* - studentHomeworkAnalysis(id) -> MSW 兜底(ReviewView 复用)
|
||||||
*
|
*
|
||||||
* 三栏布局(WorkbenchPageShell):
|
* 状态分流(依据 submissionStatus):
|
||||||
* - left:题目导航(题号 + 答题状态,点击跳转)
|
* - not_started / in_progress / undefined -> 作答工作台(TakeView):三栏布局
|
||||||
* - center:题目列表 + 答案输入区
|
* - submitted / graded -> 只读复盘视图(ReviewView):详情页布局
|
||||||
* - right:自动保存提示 + 答题统计 + 提交按钮(二次确认)
|
|
||||||
*
|
*
|
||||||
* 答案本地暂存:localStorage key `homework-answers-${id}`,防刷新丢失。
|
* 答案本地暂存:localStorage key `homework-answers-${id}`,防刷新丢失。
|
||||||
*
|
*
|
||||||
@@ -18,25 +18,55 @@
|
|||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { CheckCircle2, ClipboardList, Circle } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Circle,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useStudentHomeworkAnalysis,
|
||||||
useStudentHomeworkSubmit,
|
useStudentHomeworkSubmit,
|
||||||
useSubmitStudentHomework,
|
useSubmitStudentHomework,
|
||||||
|
type StudentHomeworkAnalysis,
|
||||||
|
type StudentHomeworkAnalysisQuestion,
|
||||||
type StudentHomeworkSubmit,
|
type StudentHomeworkSubmit,
|
||||||
type StudentHomeworkSubmitQuestion,
|
type StudentHomeworkSubmitQuestion,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
DetailPageShell,
|
||||||
|
DetailPageSkeleton,
|
||||||
|
DetailSection,
|
||||||
|
DetailField,
|
||||||
WorkbenchPageShell,
|
WorkbenchPageShell,
|
||||||
WorkbenchPageSkeleton,
|
WorkbenchPageSkeleton,
|
||||||
WorkbenchPanel,
|
WorkbenchPanel,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
/** 已提交/已批改状态归一化后需切换到 ReviewView 的取值集合。 */
|
||||||
|
const REVIEW_STATUSES = new Set(["submitted", "graded"]);
|
||||||
|
|
||||||
|
/** 将后端 status 字段归一化为小写英文枚举(兼容 MSW 中文与未来英文枚举)。 */
|
||||||
|
function normalizeSubmissionStatus(status: string | undefined): string {
|
||||||
|
if (!status) return "";
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s.includes("submit") || s.includes("已提交")) return "submitted";
|
||||||
|
if (s.includes("grad") || s.includes("已批改") || s.includes("已完成")) {
|
||||||
|
return "graded";
|
||||||
|
}
|
||||||
|
if (s.includes("not_started") || s.includes("未开始")) return "not_started";
|
||||||
|
if (s.includes("in_progress") || s.includes("进行中")) return "in_progress";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 作答工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 作答工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
*/
|
*/
|
||||||
@@ -46,29 +76,61 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const homeworkId = params?.id ?? "";
|
const homeworkId = params?.id ?? "";
|
||||||
|
|
||||||
const { data, loading, error } = useStudentHomeworkSubmit(homeworkId);
|
const { data, loading, error } = useStudentHomeworkSubmit(homeworkId);
|
||||||
|
const normalizedStatus = normalizeSubmissionStatus(data?.submissionStatus);
|
||||||
|
const isReviewMode = REVIEW_STATUSES.has(normalizedStatus);
|
||||||
|
if (isReviewMode && data) {
|
||||||
|
return <ReviewView homeworkId={homeworkId} submission={data} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TakeView
|
||||||
|
homeworkId={homeworkId}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
t={t}
|
||||||
|
tCommon={tCommon}
|
||||||
|
router={router}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TakeViewProps {
|
||||||
|
homeworkId: string;
|
||||||
|
data: StudentHomeworkSubmit | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
tCommon: ReturnType<typeof useTranslations>;
|
||||||
|
router: ReturnType<typeof useRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TakeView({
|
||||||
|
homeworkId,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
t,
|
||||||
|
tCommon,
|
||||||
|
router,
|
||||||
|
}: TakeViewProps): React.ReactElement {
|
||||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
const [confirmOpen, setConfirmOpen] = useState<boolean>(false);
|
const [confirmOpen, setConfirmOpen] = useState<boolean>(false);
|
||||||
const [activeQuestion, setActiveQuestion] = useState<number>(1);
|
const [activeQuestion, setActiveQuestion] = useState<number>(1);
|
||||||
const initialized = useRef<boolean>(false);
|
const initialized = useRef<boolean>(false);
|
||||||
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
const storageKey = `homework-answers-${homeworkId}`;
|
const storageKey = `homework-answers-${homeworkId}`;
|
||||||
|
|
||||||
// 首次拿到数据时初始化答案(localStorage 优先,回退到后端已存答案)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || initialized.current) return;
|
if (!data || initialized.current) return;
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
|
|
||||||
const saved = localStorage.getItem(storageKey);
|
const saved = localStorage.getItem(storageKey);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
setAnswers(JSON.parse(saved) as Record<string, string>);
|
setAnswers(JSON.parse(saved) as Record<string, string>);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch (err) {
|
||||||
// 存储损坏,回退到后端数据
|
console.warn("[portal-shell] restore homework answers failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const initial: Record<string, string> = {};
|
const initial: Record<string, string> = {};
|
||||||
@@ -80,7 +142,6 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
|||||||
setAnswers(initial);
|
setAnswers(initial);
|
||||||
}, [data, storageKey]);
|
}, [data, storageKey]);
|
||||||
|
|
||||||
// 答案变化时自动保存到 localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized.current) return;
|
if (!initialized.current) return;
|
||||||
localStorage.setItem(storageKey, JSON.stringify(answers));
|
localStorage.setItem(storageKey, JSON.stringify(answers));
|
||||||
@@ -149,6 +210,8 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
|||||||
answers={answers}
|
answers={answers}
|
||||||
activeQuestion={activeQuestion}
|
activeQuestion={activeQuestion}
|
||||||
onNavigate={scrollToQuestion}
|
onNavigate={scrollToQuestion}
|
||||||
|
maxAttempts={data.maxAttempts}
|
||||||
|
attemptsUsed={data.attemptsUsed}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -190,22 +253,32 @@ export function StudentHomeworkSubmitClient(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 左栏:题目导航(题号 + 答题状态)。
|
|
||||||
*/
|
|
||||||
function QuestionNavPanel({
|
function QuestionNavPanel({
|
||||||
questions,
|
questions,
|
||||||
answers,
|
answers,
|
||||||
activeQuestion,
|
activeQuestion,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
maxAttempts,
|
||||||
|
attemptsUsed,
|
||||||
}: {
|
}: {
|
||||||
questions: StudentHomeworkSubmitQuestion[];
|
questions: StudentHomeworkSubmitQuestion[];
|
||||||
answers: Record<string, string>;
|
answers: Record<string, string>;
|
||||||
activeQuestion: number;
|
activeQuestion: number;
|
||||||
onNavigate: (questionNo: number) => void;
|
onNavigate: (questionNo: number) => void;
|
||||||
|
maxAttempts?: number;
|
||||||
|
attemptsUsed?: number;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.homework.submit");
|
||||||
return (
|
return (
|
||||||
<WorkbenchPanel title="题目导航">
|
<WorkbenchPanel title={t("questionNav")}>
|
||||||
|
{typeof maxAttempts === "number" ? (
|
||||||
|
<div className="mb-3 rounded-md border bg-muted/30 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("attemptsUsed")}</p>
|
||||||
|
<p className="mt-1 text-sm font-medium">
|
||||||
|
{t("attemptsValue", { used: attemptsUsed ?? 0, max: maxAttempts })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<ol className="space-y-1">
|
<ol className="space-y-1">
|
||||||
{questions.map((q) => {
|
{questions.map((q) => {
|
||||||
const answered =
|
const answered =
|
||||||
@@ -237,9 +310,6 @@ function QuestionNavPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 中栏:题目列表 + 答案输入区。
|
|
||||||
*/
|
|
||||||
function QuestionListPanel({
|
function QuestionListPanel({
|
||||||
data,
|
data,
|
||||||
answers,
|
answers,
|
||||||
@@ -271,9 +341,6 @@ function QuestionListPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 单题卡片(题干 + 答案输入)。
|
|
||||||
*/
|
|
||||||
function QuestionCard({
|
function QuestionCard({
|
||||||
question,
|
question,
|
||||||
answer,
|
answer,
|
||||||
@@ -287,9 +354,9 @@ function QuestionCard({
|
|||||||
questionRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
|
questionRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.homework.submit");
|
||||||
const isChoice =
|
const isChoice =
|
||||||
question.type === "single_choice" && question.options.length > 0;
|
question.type === "single_choice" && question.options.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -304,7 +371,6 @@ function QuestionCard({
|
|||||||
</span>
|
</span>
|
||||||
<p className="flex-1 text-sm">{question.question}</p>
|
<p className="flex-1 text-sm">{question.question}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isChoice ? (
|
{isChoice ? (
|
||||||
<div className="space-y-2 pl-8">
|
<div className="space-y-2 pl-8">
|
||||||
{question.options.map((opt, idx) => (
|
{question.options.map((opt, idx) => (
|
||||||
@@ -325,21 +391,20 @@ function QuestionCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<textarea
|
<div className="pl-8">
|
||||||
value={answer}
|
<textarea
|
||||||
onChange={(e) => onAnswerChange(e.target.value)}
|
value={answer}
|
||||||
placeholder="请输入答案..."
|
onChange={(e) => onAnswerChange(e.target.value)}
|
||||||
rows={4}
|
placeholder={t("answerPlaceholder")}
|
||||||
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]"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 右栏:自动保存提示 + 答题统计 + 提交按钮。
|
|
||||||
*/
|
|
||||||
function SubmitPanel({
|
function SubmitPanel({
|
||||||
answeredCount,
|
answeredCount,
|
||||||
totalQuestions,
|
totalQuestions,
|
||||||
@@ -355,15 +420,15 @@ function SubmitPanel({
|
|||||||
submitDisabled: boolean;
|
submitDisabled: boolean;
|
||||||
onSubmitClick: () => void;
|
onSubmitClick: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.homework.submit");
|
||||||
return (
|
return (
|
||||||
<WorkbenchPanel title="提交">
|
<WorkbenchPanel title={t("submitPanelTitle")}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border bg-background p-3">
|
<div className="rounded-md border bg-background p-3">
|
||||||
<p className="text-xs text-emerald-600">{autoSaveTip}</p>
|
<p className="text-xs text-emerald-600">{autoSaveTip}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border bg-background p-4">
|
<div className="rounded-md border bg-background p-4">
|
||||||
<p className="text-xs text-muted-foreground">答题进度</p>
|
<p className="text-xs text-muted-foreground">{t("answerProgress")}</p>
|
||||||
<p className="mt-1 text-2xl font-semibold">
|
<p className="mt-1 text-2xl font-semibold">
|
||||||
{answeredCount}
|
{answeredCount}
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
@@ -380,7 +445,6 @@ function SubmitPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -394,9 +458,6 @@ function SubmitPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 提交确认对话框(二次确认)。
|
|
||||||
*/
|
|
||||||
function ConfirmDialog({
|
function ConfirmDialog({
|
||||||
message,
|
message,
|
||||||
cancelLabel,
|
cancelLabel,
|
||||||
@@ -432,3 +493,236 @@ function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReviewViewProps {
|
||||||
|
homeworkId: string;
|
||||||
|
submission: StudentHomeworkSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewView({
|
||||||
|
homeworkId,
|
||||||
|
submission,
|
||||||
|
}: ReviewViewProps): React.ReactElement {
|
||||||
|
const t = useTranslations("studentDomain.homework.submit");
|
||||||
|
const tAnalysis = useTranslations("studentDomain.homework.analysis");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const {
|
||||||
|
data: analysis,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useStudentHomeworkAnalysis(homeworkId);
|
||||||
|
const normalizedStatus = normalizeSubmissionStatus(
|
||||||
|
submission.submissionStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorNode = error ? (
|
||||||
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{tCommon("error.loadFailed", { message: String(error) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailPageShell
|
||||||
|
title={submission.title}
|
||||||
|
description={submission.dueDate}
|
||||||
|
icon={<ClipboardList className="size-6" />}
|
||||||
|
backHref="/shell/student/homework"
|
||||||
|
loading={loading}
|
||||||
|
loadingNode={<DetailPageSkeleton />}
|
||||||
|
errorNode={errorNode}
|
||||||
|
actions={
|
||||||
|
<Link
|
||||||
|
href={`/shell/student/homework/${homeworkId}/analysis`}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-md border border-input bg-background px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-4" />
|
||||||
|
{t("viewAnalysis")}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReviewBody
|
||||||
|
submission={submission}
|
||||||
|
analysis={analysis}
|
||||||
|
normalizedStatus={normalizedStatus}
|
||||||
|
t={t}
|
||||||
|
tAnalysis={tAnalysis}
|
||||||
|
/>
|
||||||
|
</DetailPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewBody({
|
||||||
|
submission,
|
||||||
|
analysis,
|
||||||
|
normalizedStatus,
|
||||||
|
t,
|
||||||
|
tAnalysis,
|
||||||
|
}: {
|
||||||
|
submission: StudentHomeworkSubmit;
|
||||||
|
analysis: StudentHomeworkAnalysis | undefined;
|
||||||
|
normalizedStatus: string;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
tAnalysis: ReturnType<typeof useTranslations>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title={t("reviewTitle")}>
|
||||||
|
<DetailField label={t("fieldTitle")} value={submission.title} />
|
||||||
|
<DetailField label={t("fieldDueDate")} value={submission.dueDate} />
|
||||||
|
<DetailField
|
||||||
|
label={t("fieldSubmittedAt")}
|
||||||
|
value={submission.submittedAt ?? "--"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("fieldStatus")}
|
||||||
|
value={<ReviewStatusBadge status={normalizedStatus} />}
|
||||||
|
/>
|
||||||
|
{analysis ? (
|
||||||
|
<>
|
||||||
|
<DetailField
|
||||||
|
label={t("fieldScore")}
|
||||||
|
value={`${analysis.score} / ${analysis.totalScore}`}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={tAnalysis("fieldScoreRate")}
|
||||||
|
value={`${(analysis.scoreRate * 100).toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{typeof submission.maxAttempts === "number" ? (
|
||||||
|
<DetailField
|
||||||
|
label={t("maxAttempts")}
|
||||||
|
value={t("attemptsValue", {
|
||||||
|
used: submission.attemptsUsed ?? 0,
|
||||||
|
max: submission.maxAttempts,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DetailSection>
|
||||||
|
{analysis ? (
|
||||||
|
<DetailSection title={tAnalysis("sectionQuestions")}>
|
||||||
|
<ReviewQuestionTable
|
||||||
|
submission={submission}
|
||||||
|
analysisQuestions={analysis.questions}
|
||||||
|
t={t}
|
||||||
|
tAnalysis={tAnalysis}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
) : null}
|
||||||
|
<DetailSection title={t("teacherComment")}>
|
||||||
|
{submission.feedback ? (
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{submission.feedback}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("noComment")}</p>
|
||||||
|
)}
|
||||||
|
</DetailSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewQuestionTable({
|
||||||
|
submission,
|
||||||
|
analysisQuestions,
|
||||||
|
t,
|
||||||
|
tAnalysis,
|
||||||
|
}: {
|
||||||
|
submission: StudentHomeworkSubmit;
|
||||||
|
analysisQuestions: StudentHomeworkAnalysisQuestion[];
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
tAnalysis: ReturnType<typeof useTranslations>;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const stemByNo = new Map<number, string>();
|
||||||
|
for (const q of submission.questions) {
|
||||||
|
stemByNo.set(q.questionNo, q.question);
|
||||||
|
}
|
||||||
|
if (analysisQuestions.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">--</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-2 pr-4 font-medium">
|
||||||
|
{tAnalysis("colQuestionNo")}
|
||||||
|
</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">{t("questionStem")}</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">
|
||||||
|
{tAnalysis("colYourAnswer")}
|
||||||
|
</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">
|
||||||
|
{tAnalysis("colCorrectAnswer")}
|
||||||
|
</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">
|
||||||
|
{tAnalysis("colScore")}
|
||||||
|
</th>
|
||||||
|
<th className="py-2 pr-4 text-center font-medium">
|
||||||
|
{tAnalysis("colIsCorrect")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analysisQuestions.map((q) => {
|
||||||
|
const stem = stemByNo.get(q.questionNo) ?? "--";
|
||||||
|
return (
|
||||||
|
<tr key={q.questionNo} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-4 font-medium">{q.questionNo}</td>
|
||||||
|
<td className="py-2 pr-4">{stem}</td>
|
||||||
|
<td className="py-2 pr-4">{q.yourAnswer || "--"}</td>
|
||||||
|
<td className="py-2 pr-4">{q.correctAnswer}</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono">{q.score}</td>
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
{q.isCorrect ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||||
|
<Check className="size-4" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-destructive">
|
||||||
|
<X className="size-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewStatusBadge({ status }: { status: string }): React.ReactElement {
|
||||||
|
const cls = reviewStatusToBadgeClass(status);
|
||||||
|
const label = reviewStatusToLabel(status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewStatusToBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "submitted":
|
||||||
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200";
|
||||||
|
case "graded":
|
||||||
|
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewStatusToLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "submitted":
|
||||||
|
return "Submitted";
|
||||||
|
case "graded":
|
||||||
|
return "Graded";
|
||||||
|
default:
|
||||||
|
return status || "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,17 +8,21 @@
|
|||||||
* - studentLearningCenter ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentLearningCenter ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
*
|
*
|
||||||
|
* 复刻 CICD src/app/(dashboard)/student/learning/page.tsx:
|
||||||
|
* - 3 张导航卡片(课程 / 作业 / 教材)
|
||||||
|
* - 每张卡片含图标 + 标题 + 描述 + 统计 + 箭头
|
||||||
|
* - 学生身份校验:无身份(data 为 null 且非 loading/error)显示 EmptyState
|
||||||
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
AlertCircle,
|
Library,
|
||||||
PencilLine,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
UserX,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -39,7 +43,7 @@ interface LearningCardConfig {
|
|||||||
titleKey: string;
|
titleKey: string;
|
||||||
descKey: string;
|
descKey: string;
|
||||||
href: string;
|
href: string;
|
||||||
stat: number | string;
|
stat: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +70,9 @@ export function StudentLearningCenterClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
// 学生身份校验:data 为 null 且非 loading/error 时视为无学生身份
|
||||||
|
const noStudent = !loading && !error && !data;
|
||||||
|
|
||||||
const cards: LearningCardConfig[] = [
|
const cards: LearningCardConfig[] = [
|
||||||
{
|
{
|
||||||
key: "courses",
|
key: "courses",
|
||||||
@@ -73,7 +80,7 @@ export function StudentLearningCenterClient(): React.ReactElement {
|
|||||||
titleKey: "cardCourses",
|
titleKey: "cardCourses",
|
||||||
descKey: "cardCoursesDesc",
|
descKey: "cardCoursesDesc",
|
||||||
href: "/shell/student/courses",
|
href: "/shell/student/courses",
|
||||||
stat: data?.coursesCount ?? 0,
|
stat: t("statCourses", { count: data?.coursesCount ?? 0 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "homework",
|
key: "homework",
|
||||||
@@ -81,41 +88,28 @@ export function StudentLearningCenterClient(): React.ReactElement {
|
|||||||
titleKey: "cardHomework",
|
titleKey: "cardHomework",
|
||||||
descKey: "cardHomeworkDesc",
|
descKey: "cardHomeworkDesc",
|
||||||
href: "/shell/student/homework",
|
href: "/shell/student/homework",
|
||||||
stat:
|
stat: t("statHomework", {
|
||||||
data != null
|
pending: data?.homeworkPending ?? 0,
|
||||||
? `${data.homeworkPending}/${data.homeworkDueSoon}`
|
dueSoon: data?.homeworkDueSoon ?? 0,
|
||||||
: "0/0",
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "textbooks",
|
key: "textbooks",
|
||||||
icon: BookOpen,
|
icon: Library,
|
||||||
titleKey: "cardTextbooks",
|
titleKey: "cardTextbooks",
|
||||||
descKey: "cardTextbooksDesc",
|
descKey: "cardTextbooksDesc",
|
||||||
href: "/shell/student/textbooks",
|
href: "/shell/student/textbooks",
|
||||||
stat: data?.textbooksCount ?? 0,
|
stat: t("statTextbooks", { count: data?.textbooksCount ?? 0 }),
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "practice",
|
|
||||||
icon: PencilLine,
|
|
||||||
titleKey: "cardPractice",
|
|
||||||
descKey: "cardPracticeDesc",
|
|
||||||
href: "/shell/student/practice",
|
|
||||||
stat: "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "errorBook",
|
|
||||||
icon: AlertCircle,
|
|
||||||
titleKey: "cardErrorBook",
|
|
||||||
descKey: "cardErrorBookDesc",
|
|
||||||
href: "/shell/student/error-book",
|
|
||||||
stat: "—",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyNode =
|
const emptyNode = noStudent ? (
|
||||||
!loading && !error && !data ? (
|
<EmptyState
|
||||||
<EmptyState icon={BookOpen} title={t("emptyTitle")} />
|
icon={UserX}
|
||||||
) : undefined;
|
title={t("noStudentTitle")}
|
||||||
|
description={t("noStudentDesc")}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
@@ -163,7 +157,7 @@ function LearningCard({
|
|||||||
<h3 className="text-base font-semibold">{t(config.titleKey)}</h3>
|
<h3 className="text-base font-semibold">{t(config.titleKey)}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t(config.descKey)}</p>
|
<p className="text-sm text-muted-foreground">{t(config.descKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto text-2xl font-bold tracking-tight tabular-nums">
|
<div className="mt-auto text-sm font-medium tabular-nums">
|
||||||
{config.stat}
|
{config.stat}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,35 +4,37 @@
|
|||||||
* 学生在线请假页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3)
|
* 学生在线请假页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3)
|
||||||
*
|
*
|
||||||
* 数据契约(@contract-pending 全 MSW):
|
* 数据契约(@contract-pending 全 MSW):
|
||||||
* - studentLeave:❌ schema 无此字段 → MSW 兜底
|
* - studentLeave:❌ schema 无此字段 → MSW 兜底(支持分页 page/pageSize)
|
||||||
* - submitLeaveRequest mutation:❌ schema 无此字段 → MSW 兜底
|
* - submitLeaveRequest mutation:❌ schema 无此字段 → MSW 兜底
|
||||||
* - studentClasses(用于班级下拉默认值):❌ schema 无此字段 → MSW 兜底
|
* - studentClasses(用于班级下拉默认值):❌ schema 无此字段 → MSW 兜底
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-leave
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-leave
|
||||||
*
|
*
|
||||||
|
* 组成:
|
||||||
|
* - LeaveRequestForm:表单 + 校验 + 提交(独立组件)
|
||||||
|
* - LeaveRequestList:列表 + 分页 + 空态(独立组件)
|
||||||
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(记录空态)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(记录空态)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { CalendarClock } from "lucide-react";
|
import { ArrowLeft, CalendarClock } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { useStudentClasses, useStudentLeave } from "@/lib/api";
|
||||||
useStudentClasses,
|
|
||||||
useStudentLeave,
|
|
||||||
useSubmitLeaveRequest,
|
|
||||||
type LeaveRequestInput,
|
|
||||||
type StudentLeaveItem,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
|
||||||
import {
|
import {
|
||||||
DetailPageShell,
|
DetailPageShell,
|
||||||
DetailPageSkeleton,
|
DetailPageSkeleton,
|
||||||
DetailSection,
|
DetailSection,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { LeaveRequestForm } from "./leave-request-form";
|
||||||
|
import { LeaveRequestList } from "./leave-request-list";
|
||||||
|
|
||||||
|
/** 默认每页记录数 */
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在线请假客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 在线请假客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
@@ -41,81 +43,42 @@ export function StudentLeaveClient(): React.ReactElement {
|
|||||||
const t = useTranslations("studentDomain.leave");
|
const t = useTranslations("studentDomain.leave");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
const [page, setPage] = useState(1);
|
||||||
const { data: leaveItems, loading, error, refetch } = useStudentLeave();
|
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
// @contract-pending:MSW 兜底(班级下拉默认值来源)
|
|
||||||
|
// @contract-pending:MSW 兜底(支持分页)
|
||||||
|
const {
|
||||||
|
data: leaveResult,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useStudentLeave({ page, pageSize });
|
||||||
|
// @contract-pending:MSW 兜底(用于自动写入 classId,由 LeaveRequestForm 内部消费)
|
||||||
const { data: classes } = useStudentClasses();
|
const { data: classes } = useStudentClasses();
|
||||||
// @contract-pending:MSW 兜底
|
|
||||||
const { run: submitLeave, loading: submitting } = useSubmitLeaveRequest();
|
|
||||||
|
|
||||||
const [classId, setClassId] = useState("");
|
// 自动选择第一个活跃班级作为 defaultClassId(透传给表单)
|
||||||
const [startDate, setStartDate] = useState("");
|
const defaultClassId = (classes ?? []).find((c) => c.isActive)?.id ?? "";
|
||||||
const [endDate, setEndDate] = useState("");
|
|
||||||
const [type, setType] = useState("");
|
|
||||||
const [reason, setReason] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 默认选择第一个活跃班级
|
const items = leaveResult?.items ?? [];
|
||||||
useEffect(() => {
|
const total = leaveResult?.total ?? 0;
|
||||||
if (!classId && classes) {
|
const currentPage = leaveResult?.page ?? page;
|
||||||
const firstActive = classes.find((c) => c.isActive);
|
const currentPageSize = leaveResult?.pageSize ?? pageSize;
|
||||||
if (firstActive) setClassId(firstActive.id);
|
|
||||||
}
|
|
||||||
}, [classes, classId]);
|
|
||||||
|
|
||||||
const hasActiveClass = (classes ?? []).some((c) => c.isActive);
|
const handlePageChange = useCallback((next: number): void => {
|
||||||
|
setPage(Math.max(1, next));
|
||||||
const handleFormSubmit = (): void => {
|
}, []);
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!classId) {
|
|
||||||
setFormError("请选择班级");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!startDate) {
|
|
||||||
setFormError("请选择开始日期");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!endDate) {
|
|
||||||
setFormError("请选择结束日期");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!type) {
|
|
||||||
setFormError("请选择请假类型");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!reason.trim()) {
|
|
||||||
setFormError("请填写请假原因");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (endDate < startDate) {
|
|
||||||
setFormError("结束日期不能早于开始日期");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input: LeaveRequestInput = {
|
|
||||||
classId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
reason: reason.trim(),
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const handleFormSubmitted = useCallback((): void => {
|
||||||
|
// 提交成功后回到第 1 页并刷新
|
||||||
|
setPage(1);
|
||||||
void (async (): Promise<void> => {
|
void (async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await submitLeave(input);
|
|
||||||
notify.success(t("submitSuccess"));
|
|
||||||
// 重置表单(保留班级选择),刷新历史
|
|
||||||
setStartDate("");
|
|
||||||
setEndDate("");
|
|
||||||
setType("");
|
|
||||||
setReason("");
|
|
||||||
await refetch();
|
await refetch();
|
||||||
} catch {
|
} catch (err) {
|
||||||
notify.error(t("submitError"));
|
notify.error(tCommon("error.loadFailed", { message: String(err) }));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
};
|
}, [refetch, tCommon]);
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
@@ -135,234 +98,31 @@ export function StudentLeaveClient(): React.ReactElement {
|
|||||||
loadingNode={<DetailPageSkeleton />}
|
loadingNode={<DetailPageSkeleton />}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
>
|
>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2 gap-2">
|
||||||
|
<Link href="/shell/student">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
{t("backToDashboard")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DetailSection title={t("sectionForm")}>
|
<DetailSection title={t("sectionForm")}>
|
||||||
{!hasActiveClass ? (
|
<LeaveRequestForm
|
||||||
<p className="text-sm text-muted-foreground">
|
defaultClassId={defaultClassId}
|
||||||
{t("noActiveClass")}
|
onSubmitted={handleFormSubmitted}
|
||||||
</p>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FormField label={t("fieldClassId")} required>
|
|
||||||
<select
|
|
||||||
value={classId}
|
|
||||||
onChange={(e) => setClassId(e.target.value)}
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
disabled={!hasActiveClass}
|
|
||||||
>
|
|
||||||
<option value="">{t("fieldClassId")}</option>
|
|
||||||
{(classes ?? []).map((cls) => (
|
|
||||||
<option key={cls.id} value={cls.id}>
|
|
||||||
{cls.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label={t("fieldStartDate")} required>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label={t("fieldEndDate")} required>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label={t("fieldType")} required>
|
|
||||||
<select
|
|
||||||
value={type}
|
|
||||||
onChange={(e) => setType(e.target.value)}
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">{t("fieldType")}</option>
|
|
||||||
<option value="personal">{t("typePersonal")}</option>
|
|
||||||
<option value="sick">{t("typeSick")}</option>
|
|
||||||
<option value="other">{t("typeOther")}</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label={t("fieldReason")} required>
|
|
||||||
<textarea
|
|
||||||
value={reason}
|
|
||||||
onChange={(e) => setReason(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{formError ? (
|
|
||||||
<p className="text-sm text-destructive">{formError}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleFormSubmit}
|
|
||||||
disabled={submitting || !hasActiveClass}
|
|
||||||
>
|
|
||||||
{t("submit")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title={t("sectionHistory")}>
|
<DetailSection title={t("sectionHistory")}>
|
||||||
<LeaveHistoryTable items={leaveItems ?? []} />
|
<LeaveRequestList
|
||||||
|
items={items}
|
||||||
|
total={total}
|
||||||
|
page={currentPage}
|
||||||
|
pageSize={currentPageSize}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
</DetailPageShell>
|
</DetailPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 表单字段容器(label + children)。
|
|
||||||
*/
|
|
||||||
function FormField({
|
|
||||||
label,
|
|
||||||
required,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
required?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}
|
|
||||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请假记录历史表格(含空态)。
|
|
||||||
*/
|
|
||||||
function LeaveHistoryTable({
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
items: StudentLeaveItem[];
|
|
||||||
}): React.ReactElement {
|
|
||||||
const t = useTranslations("studentDomain.leave");
|
|
||||||
if (items.length === 0) {
|
|
||||||
return <EmptyState icon={CalendarClock} title={t("emptyTitle")} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b bg-muted/30">
|
|
||||||
<tr>
|
|
||||||
<th className="p-3 text-left font-medium">{t("colStartDate")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("colEndDate")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("colType")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("colReason")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{items.map((item) => (
|
|
||||||
<tr key={item.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
|
||||||
{formatDate(item.startDate)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
|
||||||
{formatDate(item.endDate)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">{formatLeaveType(item.type, t)}</td>
|
|
||||||
<td className="p-3 text-xs text-muted-foreground">
|
|
||||||
{item.reason}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<LeaveStatusBadge status={item.status} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请假状态徽章(pending 琥珀 / approved 翡翠 / rejected 危险色)。
|
|
||||||
*/
|
|
||||||
function LeaveStatusBadge({ status }: { status: string }): React.ReactElement {
|
|
||||||
const t = useTranslations("studentDomain.leave");
|
|
||||||
const label = leaveStatusToLabel(status, t);
|
|
||||||
const cls = leaveStatusToBadgeClass(status);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium",
|
|
||||||
cls,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将请假状态枚举值映射为 i18n 标签。未知状态回退为原始值。 */
|
|
||||||
function leaveStatusToLabel(
|
|
||||||
status: string,
|
|
||||||
t: ReturnType<typeof useTranslations>,
|
|
||||||
): string {
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return t("statusPending");
|
|
||||||
case "approved":
|
|
||||||
return t("statusApproved");
|
|
||||||
case "rejected":
|
|
||||||
return t("statusRejected");
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据请假状态返回 Tailwind 徽章类名。 */
|
|
||||||
function leaveStatusToBadgeClass(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
|
||||||
case "approved":
|
|
||||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
|
||||||
case "rejected":
|
|
||||||
return "bg-destructive/10 text-destructive";
|
|
||||||
default:
|
|
||||||
return "bg-muted text-muted-foreground";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将请假类型枚举值映射为 i18n 标签。未知类型回退为原始值。 */
|
|
||||||
function formatLeaveType(
|
|
||||||
type: string,
|
|
||||||
t: ReturnType<typeof useTranslations>,
|
|
||||||
): string {
|
|
||||||
switch (type) {
|
|
||||||
case "personal":
|
|
||||||
return t("typePersonal");
|
|
||||||
case "sick":
|
|
||||||
return t("typeSick");
|
|
||||||
case "other":
|
|
||||||
return t("typeOther");
|
|
||||||
default:
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化 ISO 日期字符串为本地化展示。 */
|
|
||||||
function formatDate(isoDate: string): string {
|
|
||||||
if (!isoDate) return "--";
|
|
||||||
const d = new Date(isoDate);
|
|
||||||
if (Number.isNaN(d.getTime())) return "--";
|
|
||||||
return d.toLocaleDateString("zh-CN");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - subjectOptions ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
@@ -15,9 +16,19 @@
|
|||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useStudentLessonPlans, type StudentLessonPlan } from "@/lib/api";
|
import {
|
||||||
|
useStudentLessonPlans,
|
||||||
|
useSubjectOptions,
|
||||||
|
type StudentLessonPlan,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
@@ -34,6 +45,10 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
|||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useStudentLessonPlans();
|
const { data, loading, error } = useStudentLessonPlans();
|
||||||
|
const { data: subjectOptions } = useSubjectOptions();
|
||||||
|
|
||||||
|
// 学科筛选状态(subject id)
|
||||||
|
const [subjectFilter, setSubjectFilter] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -41,7 +56,16 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [error, tCommon]);
|
}, [error, tCommon]);
|
||||||
|
|
||||||
const items = data ?? [];
|
// 仅展示已发布教案(不在前端展示草稿),并按学科筛选
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
const published = (data ?? []).filter((p) => p.status === "published");
|
||||||
|
if (!subjectFilter) return published;
|
||||||
|
const subjectName = (subjectOptions ?? []).find(
|
||||||
|
(s) => s.id === subjectFilter,
|
||||||
|
)?.name;
|
||||||
|
if (!subjectName) return published;
|
||||||
|
return published.filter((p) => p.subject === subjectName);
|
||||||
|
}, [data, subjectFilter, subjectOptions]);
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
@@ -68,71 +92,88 @@ export function StudentLessonPlansListClient(): React.ReactElement {
|
|||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<FileText className="size-6" />}
|
icon={<FileText className="size-6" />}
|
||||||
|
filters={
|
||||||
|
<select
|
||||||
|
value={subjectFilter}
|
||||||
|
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm md:w-60"
|
||||||
|
aria-label={t("subjectFilter")}
|
||||||
|
>
|
||||||
|
<option value="">{t("allSubjects")}</option>
|
||||||
|
{(subjectOptions ?? []).map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
empty={items.length === 0 && !loading}
|
empty={visibleItems.length === 0 && !loading}
|
||||||
emptyNode={emptyNode}
|
emptyNode={emptyNode}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
<span>{t("total", { count: items.length })}</span>
|
<span>{t("total", { count: visibleItems.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StudentLessonPlansTable items={items} />
|
<StudentLessonPlansGrid items={visibleItems} />
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
* 学生教案卡片网格(md:grid-cols-2 lg:grid-cols-3,复刻 CICD lesson-plan-list)。
|
||||||
*/
|
*/
|
||||||
function StudentLessonPlansTable({
|
function StudentLessonPlansGrid({
|
||||||
items,
|
items,
|
||||||
}: {
|
}: {
|
||||||
items: StudentLessonPlan[];
|
items: StudentLessonPlan[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.lessonPlans.list");
|
const t = useTranslations("studentDomain.lessonPlans.list");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<table className="w-full text-sm">
|
{items.map((plan) => (
|
||||||
<thead className="border-b bg-muted/30">
|
<Card
|
||||||
<tr>
|
key={plan.id}
|
||||||
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
|
className="group flex flex-col transition-all hover:-translate-y-1 hover:shadow-md"
|
||||||
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
>
|
||||||
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
|
<CardHeader className="pb-3">
|
||||||
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
|
<CardTitle className="line-clamp-2 text-base font-semibold">
|
||||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
{plan.title}
|
||||||
</tr>
|
</CardTitle>
|
||||||
</thead>
|
</CardHeader>
|
||||||
<tbody className="divide-y">
|
<CardContent className="flex flex-1 flex-col gap-3">
|
||||||
{items.map((plan) => (
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<tr key={plan.id} className="hover:bg-muted/30">
|
<div>
|
||||||
<td className="p-3">
|
<dt className="text-xs text-muted-foreground">
|
||||||
<Link
|
{t("colSubject")}
|
||||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
</dt>
|
||||||
className="font-medium hover:underline"
|
<dd className="font-medium">{plan.subject}</dd>
|
||||||
>
|
</div>
|
||||||
{plan.title}
|
<div>
|
||||||
</Link>
|
<dt className="text-xs text-muted-foreground">
|
||||||
</td>
|
{t("colGrade")}
|
||||||
<td className="p-3 text-muted-foreground">{plan.subject}</td>
|
</dt>
|
||||||
<td className="p-3 text-muted-foreground">{plan.grade}</td>
|
<dd className="font-medium">{plan.grade}</dd>
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
</div>
|
||||||
{plan.updatedAt}
|
</dl>
|
||||||
</td>
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
<td className="p-3 text-right">
|
{plan.updatedAt}
|
||||||
<Link
|
</p>
|
||||||
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
<div className="mt-auto pt-2">
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
<Link
|
||||||
>
|
href={`/shell/student/lesson-plans/${plan.id}/view`}
|
||||||
{t("viewDetail")}
|
className="inline-flex items-center text-xs font-medium text-primary hover:underline"
|
||||||
</Link>
|
>
|
||||||
</td>
|
{t("viewDetail")}
|
||||||
</tr>
|
</Link>
|
||||||
))}
|
</div>
|
||||||
</tbody>
|
</CardContent>
|
||||||
</table>
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
|
// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
|
||||||
|
// @contract-pending scope-check:范围校验待 core-edu 服务接入后启用
|
||||||
/**
|
/**
|
||||||
* 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
* 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
@@ -13,12 +14,16 @@
|
|||||||
* - error:errorNode 局部降级
|
* - error:errorNode 局部降级
|
||||||
* - notFound:data 为 null 时显示空态节点
|
* - 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
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useStudentLessonPlanView,
|
useStudentLessonPlanView,
|
||||||
@@ -35,6 +40,8 @@ import { notify } from "@/shared/lib/notify";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 只读查看客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
|
*
|
||||||
|
* 全高度布局(h-[calc(100vh-4rem)])+ 内部 overflow-y-auto,提升阅读体验。
|
||||||
*/
|
*/
|
||||||
export function StudentLessonPlanViewClient(): React.ReactElement {
|
export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||||
@@ -72,22 +79,49 @@ export function StudentLessonPlanViewClient(): React.ReactElement {
|
|||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPageShell
|
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
|
||||||
title={data?.title ?? t("title")}
|
<DetailPageShell
|
||||||
icon={<FileText className="size-6" />}
|
title={data?.title ?? t("title")}
|
||||||
backHref="/shell/student/lesson-plans"
|
description={data ? buildSubtitle(data, t) : undefined}
|
||||||
loading={loading}
|
icon={<FileText className="size-6" />}
|
||||||
loadingNode={<DetailPageSkeleton />}
|
backHref="/shell/student/lesson-plans"
|
||||||
errorNode={errorNode}
|
loading={loading}
|
||||||
emptyNode={emptyNode}
|
loadingNode={<DetailPageSkeleton />}
|
||||||
>
|
errorNode={errorNode}
|
||||||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
emptyNode={emptyNode}
|
||||||
</DetailPageShell>
|
>
|
||||||
|
{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({
|
function LessonPlanViewBody({
|
||||||
plan,
|
plan,
|
||||||
@@ -95,12 +129,327 @@ function LessonPlanViewBody({
|
|||||||
plan: LessonPlanViewData;
|
plan: LessonPlanViewData;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||||
return (
|
|
||||||
<DetailSection title={t("sectionContent")}>
|
// TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围
|
||||||
<DetailField label={t("title")} value={plan.title} />
|
// @contract-pending scope-check
|
||||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
|
||||||
{plan.content}
|
// 已发布状态检查:未发布时阻止渲染内容
|
||||||
|
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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
// @contract-pending:studentPractice schema 未实现,全 MSW 兜底
|
// @contract-pending: studentPractice / studentKnowledgePoints / startPracticeSession schema -> MSW
|
||||||
/**
|
import {
|
||||||
* 学生自适应练习列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P3)
|
Award,
|
||||||
*
|
CheckCircle2,
|
||||||
* 数据契约:
|
Clock,
|
||||||
* - studentPractice ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
Dumbbell,
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-practice
|
Filter,
|
||||||
*
|
Target,
|
||||||
* 三态规范(§11.3 DoD):
|
TrendingUp,
|
||||||
* - loading:ListPageSkeleton
|
XCircle,
|
||||||
* - error:errorNode 局部降级
|
} from "lucide-react";
|
||||||
* - empty:data 为空时显示空态节点
|
|
||||||
*
|
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
|
|
||||||
*/
|
|
||||||
import { Dumbbell } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useStudentPractice } from "@/lib/api";
|
import {
|
||||||
|
useStudentKnowledgePoints,
|
||||||
|
useStartPracticeSession,
|
||||||
|
useStudentPractice,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
StudentKnowledgePoint,
|
||||||
|
KnowledgePointDifficulty,
|
||||||
|
StudentSubject,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||||
|
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
/**
|
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const;
|
||||||
* 练习列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
|
||||||
*/
|
type SubjectFilter = StudentSubject | "all";
|
||||||
|
|
||||||
|
const SUBJECT_OPTIONS: ReadonlyArray<SubjectFilter> = [
|
||||||
|
"all",
|
||||||
|
"math",
|
||||||
|
"chinese",
|
||||||
|
"english",
|
||||||
|
"physics",
|
||||||
|
"chemistry",
|
||||||
|
];
|
||||||
|
|
||||||
|
type SubjectLabelKey =
|
||||||
|
| "subjectMath"
|
||||||
|
| "subjectChinese"
|
||||||
|
| "subjectEnglish"
|
||||||
|
| "subjectPhysics"
|
||||||
|
| "subjectChemistry";
|
||||||
|
|
||||||
|
const SUBJECT_LABEL_KEYS: Record<StudentSubject, SubjectLabelKey> = {
|
||||||
|
math: "subjectMath",
|
||||||
|
chinese: "subjectChinese",
|
||||||
|
english: "subjectEnglish",
|
||||||
|
physics: "subjectPhysics",
|
||||||
|
chemistry: "subjectChemistry",
|
||||||
|
};
|
||||||
|
|
||||||
|
type DifficultyLabelKey =
|
||||||
|
"difficultyEasy" | "difficultyMedium" | "difficultyHard";
|
||||||
|
|
||||||
|
const DIFFICULTY_LABEL_KEYS: Record<
|
||||||
|
KnowledgePointDifficulty,
|
||||||
|
DifficultyLabelKey
|
||||||
|
> = {
|
||||||
|
easy: "difficultyEasy",
|
||||||
|
medium: "difficultyMedium",
|
||||||
|
hard: "difficultyHard",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_ORDER: Record<KnowledgePointDifficulty, number> = {
|
||||||
|
easy: 1,
|
||||||
|
medium: 2,
|
||||||
|
hard: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_BADGE_CLASSES: Record<KnowledgePointDifficulty, string> = {
|
||||||
|
easy: "bg-emerald-500/10 text-emerald-600",
|
||||||
|
medium: "bg-amber-500/10 text-amber-600",
|
||||||
|
hard: "bg-rose-500/10 text-rose-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBJECT_FILTER_VALUES = new Set<string>([
|
||||||
|
"all",
|
||||||
|
"math",
|
||||||
|
"chinese",
|
||||||
|
"english",
|
||||||
|
"physics",
|
||||||
|
"chemistry",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isSubjectFilter(val: string): val is SubjectFilter {
|
||||||
|
return SUBJECT_FILTER_VALUES.has(val);
|
||||||
|
}
|
||||||
export function StudentPracticeListClient(): React.ReactElement {
|
export function StudentPracticeListClient(): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.practice.list");
|
const t = useTranslations("studentDomain.practice.list");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
|
||||||
const { data, loading, error } = useStudentPractice();
|
const { data, loading, error } = useStudentPractice();
|
||||||
|
const { run: startSession, loading: creating } = useStartPracticeSession();
|
||||||
|
const {
|
||||||
|
data: knowledgePoints,
|
||||||
|
loading: kpLoading,
|
||||||
|
error: kpError,
|
||||||
|
} = useStudentKnowledgePoints();
|
||||||
|
|
||||||
|
const [selectedKpIds, setSelectedKpIds] = useState<string[]>([]);
|
||||||
|
const [questionCount, setQuestionCount] = useState<number>(10);
|
||||||
|
const [subjectFilter, setSubjectFilter] = useState<SubjectFilter>("all");
|
||||||
|
|
||||||
|
const filteredKps = useMemo<StudentKnowledgePoint[]>(() => {
|
||||||
|
if (!knowledgePoints) return [];
|
||||||
|
if (subjectFilter === "all") return knowledgePoints;
|
||||||
|
return knowledgePoints.filter((kp) => kp.subject === subjectFilter);
|
||||||
|
}, [knowledgePoints, subjectFilter]);
|
||||||
|
|
||||||
|
const groupedKps = useMemo<
|
||||||
|
Map<StudentSubject, StudentKnowledgePoint[]>
|
||||||
|
>(() => {
|
||||||
|
const groups = new Map<StudentSubject, StudentKnowledgePoint[]>();
|
||||||
|
for (const kp of filteredKps) {
|
||||||
|
const arr = groups.get(kp.subject) ?? [];
|
||||||
|
arr.push(kp);
|
||||||
|
groups.set(kp.subject, arr);
|
||||||
|
}
|
||||||
|
for (const arr of groups.values()) {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const d =
|
||||||
|
DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty];
|
||||||
|
return d !== 0 ? d : a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [filteredKps]);
|
||||||
|
|
||||||
|
const handleChangeSubject = (next: SubjectFilter): void => {
|
||||||
|
setSubjectFilter(next);
|
||||||
|
setSelectedKpIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleKp = (kpId: string): void => {
|
||||||
|
setSelectedKpIds((prev) =>
|
||||||
|
prev.includes(kpId) ? prev.filter((id) => id !== kpId) : [...prev, kpId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = (): void => {
|
||||||
|
if (selectedKpIds.length === 0) {
|
||||||
|
notify.error(t("starterSelectKnowledgePoint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const sessionId = await startSession(selectedKpIds);
|
||||||
|
notify.success(t("starterCreated"));
|
||||||
|
router.push(`/shell/student/practice/${sessionId}`);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(t("starterCreateFailed", { message: String(err) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -46,7 +185,13 @@ export function StudentPracticeListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const emptyNode = <EmptyState icon={Dumbbell} title={t("emptyTitle")} />;
|
const emptyNode =
|
||||||
|
!loading && !error && !data ? (
|
||||||
|
<EmptyState icon={Dumbbell} title={t("emptyTitle")} />
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
const stats = data?.stats;
|
||||||
|
const history = data?.history ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
@@ -55,119 +200,334 @@ export function StudentPracticeListClient(): React.ReactElement {
|
|||||||
icon={<Dumbbell className="size-6" />}
|
icon={<Dumbbell className="size-6" />}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
empty={!loading && !error && !data}
|
|
||||||
emptyNode={emptyNode}
|
emptyNode={emptyNode}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||||
<span>{t("mswNotice")}</span>
|
<span>{t("mswNotice")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data ? <PracticeBody data={data} /> : null}
|
{data ? (
|
||||||
|
<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>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
interface PracticeStarterProps {
|
||||||
|
filteredKps: StudentKnowledgePoint[];
|
||||||
|
groupedKps: Map<StudentSubject, StudentKnowledgePoint[]>;
|
||||||
|
selectedKpIds: string[];
|
||||||
|
questionCount: number;
|
||||||
|
subjectFilter: SubjectFilter;
|
||||||
|
kpLoading: boolean;
|
||||||
|
kpError: unknown;
|
||||||
|
onChangeSubject: (next: SubjectFilter) => void;
|
||||||
|
onToggleKp: (kpId: string) => void;
|
||||||
|
onChangeQuestionCount: (count: number) => void;
|
||||||
|
onStart: () => void;
|
||||||
|
creating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function PracticeStarter({
|
||||||
* 练习内容区(统计卡片 + 历史会话列表)。
|
filteredKps,
|
||||||
*/
|
groupedKps,
|
||||||
function PracticeBody({
|
selectedKpIds,
|
||||||
data,
|
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 {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.practice.list");
|
const t = useTranslations("studentDomain.practice.list");
|
||||||
return (
|
return (
|
||||||
<>
|
<button
|
||||||
<section className="space-y-3">
|
type="button"
|
||||||
<h2 className="text-lg font-semibold">{t("sectionStats")}</h2>
|
onClick={() => onToggle(kp.id)}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
aria-pressed={selected}
|
||||||
<StatCard
|
className={cn(
|
||||||
title={t("fieldTotalSessions")}
|
"flex w-full flex-col gap-1 rounded-md p-2 text-left transition-colors hover:bg-muted/50",
|
||||||
value={String(data.stats.totalSessions)}
|
selected
|
||||||
/>
|
? "bg-primary/10 ring-1 ring-primary"
|
||||||
<StatCard
|
: "bg-card ring-1 ring-border",
|
||||||
title={t("fieldCompletedSessions")}
|
)}
|
||||||
value={String(data.stats.completedSessions)}
|
>
|
||||||
/>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<StatCard
|
<span className="text-sm font-medium">{kp.name}</span>
|
||||||
title={t("fieldTotalQuestions")}
|
<span
|
||||||
value={String(data.stats.totalQuestions)}
|
className={cn(
|
||||||
/>
|
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
<StatCard
|
DIFFICULTY_BADGE_CLASSES[kp.difficulty],
|
||||||
title={t("fieldCorrectRate")}
|
)}
|
||||||
value={formatRate(data.stats.correctRate)}
|
>
|
||||||
/>
|
{t(DIFFICULTY_LABEL_KEYS[kp.difficulty])}
|
||||||
</div>
|
</span>
|
||||||
</section>
|
</div>
|
||||||
|
{kp.description ? (
|
||||||
<section className="space-y-3">
|
<span className="text-xs text-muted-foreground">{kp.description}</span>
|
||||||
<h2 className="text-lg font-semibold">{t("sectionHistory")}</h2>
|
) : null}
|
||||||
<PracticeHistoryTable history={data.history} />
|
<span className="text-xs text-muted-foreground/70">{kp.grade}</span>
|
||||||
</section>
|
</button>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function PracticeHistory({
|
||||||
/**
|
sessions,
|
||||||
* 历史会话表格(纯展示组件,对齐 §8.2 排版规范)。
|
|
||||||
*/
|
|
||||||
function PracticeHistoryTable({
|
|
||||||
history,
|
|
||||||
}: {
|
}: {
|
||||||
history: NonNullable<
|
sessions: NonNullable<
|
||||||
ReturnType<typeof useStudentPractice>["data"]
|
ReturnType<typeof useStudentPractice>["data"]
|
||||||
>["history"];
|
>["history"];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.practice.list");
|
const t = useTranslations("studentDomain.practice.list");
|
||||||
if (history.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("emptyTitle")}</p>;
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
<Target className="mx-auto mb-2 size-8 opacity-50" />
|
||||||
|
{t("emptyTitle")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="space-y-3">
|
||||||
<table className="w-full text-sm">
|
{sessions.map((session) => {
|
||||||
<thead className="border-b bg-muted/30">
|
const correctCount = Math.round(
|
||||||
<tr>
|
session.totalQuestions * session.correctRate,
|
||||||
<th className="p-3 text-left font-medium">{t("colSessionDate")}</th>
|
);
|
||||||
<th className="p-3 text-left font-medium">
|
const wrongCount = session.totalQuestions - correctCount;
|
||||||
{t("colTotalQuestions")}
|
return (
|
||||||
</th>
|
<Link
|
||||||
<th className="p-3 text-left font-medium">{t("colCorrectRate")}</th>
|
key={session.id}
|
||||||
<th className="p-3 text-right font-medium">{t("colActions")}</th>
|
href={`/shell/student/practice/${session.id}`}
|
||||||
</tr>
|
className="block"
|
||||||
</thead>
|
>
|
||||||
<tbody className="divide-y">
|
<Card className="transition-colors hover:bg-muted/30">
|
||||||
{history.map((item) => (
|
<CardContent className="p-4">
|
||||||
<tr key={item.id} className="hover:bg-muted/30">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
<div className="flex-1 space-y-1">
|
||||||
{formatDate(item.date)}
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
</td>
|
<span className="flex items-center gap-1">
|
||||||
<td className="p-3">{String(item.totalQuestions)}</td>
|
<Clock className="size-3" />
|
||||||
<td className="p-3">{formatRate(item.correctRate)}</td>
|
{formatDate(session.date)}
|
||||||
<td className="p-3 text-right">
|
</span>
|
||||||
<Link
|
<span className="flex items-center gap-1">
|
||||||
href={`/shell/student/practice/${item.id}`}
|
<CheckCircle2 className="size-3 text-emerald-500" />
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
{correctCount}
|
||||||
>
|
</span>
|
||||||
{t("viewDetail")}
|
<span className="flex items-center gap-1">
|
||||||
</Link>
|
<XCircle className="size-3 text-rose-500" />
|
||||||
</td>
|
{wrongCount}
|
||||||
</tr>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化 0-1 的比率为百分比字符串。 */
|
|
||||||
function formatRate(rate: number): string {
|
function formatRate(rate: number): string {
|
||||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||||
return `${(rate * 100).toFixed(1)}%`;
|
return `${(rate * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化 ISO 日期字符串为本地化展示。 */
|
|
||||||
function formatDate(isoDate: string): string {
|
function formatDate(isoDate: string): string {
|
||||||
if (!isoDate) return "--";
|
if (!isoDate) return "--";
|
||||||
const d = new Date(isoDate);
|
const d = new Date(isoDate);
|
||||||
|
|||||||
@@ -8,13 +8,15 @@
|
|||||||
* - studentClasses(用于班级筛选器选项):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentClasses(用于班级筛选器选项):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-schedule
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#student-schedule
|
||||||
*
|
*
|
||||||
* URL 状态:?classId=xxx
|
* URL 状态:?classId=xxx(默认 all)
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
|
* 复刻 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 { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -24,23 +26,38 @@ import {
|
|||||||
useStudentSchedule,
|
useStudentSchedule,
|
||||||
type StudentScheduleItem,
|
type StudentScheduleItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
/** 周一到周日(weekday 1-7 → 周一到周日)。 */
|
/** 周一到周日(weekday 1-7 → monday 到 sunday),i18n 键名映射。 */
|
||||||
const WEEKDAY_LABELS = [
|
const WEEKDAY_KEYS = [
|
||||||
"周一",
|
"monday",
|
||||||
"周二",
|
"tuesday",
|
||||||
"周三",
|
"wednesday",
|
||||||
"周四",
|
"thursday",
|
||||||
"周五",
|
"friday",
|
||||||
"周六",
|
"saturday",
|
||||||
"周日",
|
"sunday",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** JS getDay() (0=Sun..6=Sat) → 业务 weekday (1=Mon..7=Sun)。 */
|
||||||
|
function getTodayWeekday(): number {
|
||||||
|
const map = [7, 1, 2, 3, 4, 5, 6];
|
||||||
|
const day = new Date().getDay();
|
||||||
|
if (day < 0 || day > 6) return 1;
|
||||||
|
return map[day]!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 课表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
@@ -57,29 +74,20 @@ export function StudentScheduleListClient(): React.ReactElement {
|
|||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data: scheduleItems, loading, error } = useStudentSchedule();
|
const { data: scheduleItems, loading, error } = useStudentSchedule();
|
||||||
// 班级筛选器选项:studentClasses 提供班级列表
|
// 班级筛选器选项:studentClasses 提供班级列表
|
||||||
// 注意:StudentScheduleItem 当前无 classId 字段,筛选仅做 URL 状态记录,
|
|
||||||
// 实际过滤待后端契约补齐(@contract-pending)
|
|
||||||
const { data: classes } = useStudentClasses();
|
const { data: classes } = useStudentClasses();
|
||||||
|
|
||||||
const groupedByWeekday = useMemo(() => {
|
const items = scheduleItems ?? [];
|
||||||
const items = scheduleItems ?? [];
|
|
||||||
const groups: Array<{ weekday: number; items: StudentScheduleItem[] }> = [];
|
|
||||||
for (let wd = 1; wd <= 7; wd++) {
|
|
||||||
const dayItems = items
|
|
||||||
.filter((item) => item.weekday === wd)
|
|
||||||
.sort((a, b) => a.period - b.period);
|
|
||||||
if (dayItems.length > 0) {
|
|
||||||
groups.push({ weekday: wd, items: dayItems });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}, [scheduleItems]);
|
|
||||||
|
|
||||||
const totalItems = (scheduleItems ?? []).length;
|
// 按班级过滤(classId 字段已补齐,过滤实际生效)
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!classId || classId === "all") return items;
|
||||||
|
return items.filter((item) => item.classId === classId);
|
||||||
|
}, [items, classId]);
|
||||||
|
const totalItems = filteredItems.length;
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
const updateQuery = (key: string, value: string): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
if (value) {
|
if (value && value !== "all") {
|
||||||
params.set(key, value);
|
params.set(key, value);
|
||||||
} else {
|
} else {
|
||||||
params.delete(key);
|
params.delete(key);
|
||||||
@@ -98,7 +106,22 @@ export function StudentScheduleListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const emptyNode = <EmptyState icon={Calendar} title={t("emptyTitle")} />;
|
// 学生身份校验:scheduleItems 为 undefined 且非 loading/error 时视为无学生身份
|
||||||
|
const noStudent = !loading && !error && scheduleItems === undefined;
|
||||||
|
|
||||||
|
const emptyNode = noStudent ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={UserX}
|
||||||
|
title={t("noStudentTitle")}
|
||||||
|
description={t("noStudentDesc")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={CalendarX}
|
||||||
|
title={t("emptyTitle")}
|
||||||
|
description={t("emptyDescription")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
@@ -107,12 +130,12 @@ export function StudentScheduleListClient(): React.ReactElement {
|
|||||||
icon={<Calendar className="size-6" />}
|
icon={<Calendar className="size-6" />}
|
||||||
filters={
|
filters={
|
||||||
<select
|
<select
|
||||||
value={classId}
|
value={classId || "all"}
|
||||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm md:w-60"
|
||||||
aria-label={t("classFilter")}
|
aria-label={t("classFilter")}
|
||||||
>
|
>
|
||||||
<option value="">{t("allClasses")}</option>
|
<option value="all">{t("allClasses")}</option>
|
||||||
{(classes ?? []).map((cls) => (
|
{(classes ?? []).map((cls) => (
|
||||||
<option key={cls.id} value={cls.id}>
|
<option key={cls.id} value={cls.id}>
|
||||||
{cls.name}
|
{cls.name}
|
||||||
@@ -125,71 +148,114 @@ export function StudentScheduleListClient(): React.ReactElement {
|
|||||||
empty={totalItems === 0 && !loading}
|
empty={totalItems === 0 && !loading}
|
||||||
emptyNode={emptyNode}
|
emptyNode={emptyNode}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{t("mswNotice")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<StudentScheduleView items={filteredItems} />
|
||||||
{groupedByWeekday.map((group) => (
|
|
||||||
<WeekdayScheduleGroup
|
|
||||||
key={group.weekday}
|
|
||||||
weekday={group.weekday}
|
|
||||||
items={group.items}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按星期分组的课表展示。
|
* 课表周视图(复刻 CICD StudentScheduleView)。
|
||||||
|
*
|
||||||
|
* 每个星期一张卡片(lg:grid-cols-2),今日卡片高亮,
|
||||||
|
* 卡片内按 startTime 排序展示节次。
|
||||||
*/
|
*/
|
||||||
function WeekdayScheduleGroup({
|
function StudentScheduleView({
|
||||||
weekday,
|
|
||||||
items,
|
items,
|
||||||
}: {
|
}: {
|
||||||
weekday: number;
|
|
||||||
items: StudentScheduleItem[];
|
items: StudentScheduleItem[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.schedule");
|
const t = useTranslations("studentDomain.schedule");
|
||||||
const label = WEEKDAY_LABELS[weekday - 1] ?? `Day ${weekday}`;
|
const todayKey = getTodayWeekday();
|
||||||
|
|
||||||
|
const itemsByDay = useMemo(() => {
|
||||||
|
const map = new Map<number, StudentScheduleItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const list = map.get(item.weekday) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
map.set(item.weekday, list);
|
||||||
|
}
|
||||||
|
for (const list of map.values()) {
|
||||||
|
list.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border bg-card">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<header className="border-b p-4">
|
{WEEKDAY_KEYS.map((dayKey, idx) => {
|
||||||
<h3 className="text-base font-semibold">{label}</h3>
|
const weekday = idx + 1;
|
||||||
</header>
|
const dayItems = itemsByDay.get(weekday) ?? [];
|
||||||
<div className="overflow-x-auto">
|
const isToday = weekday === todayKey;
|
||||||
<table className="w-full text-sm">
|
const label = t(`weekdays.${dayKey}`);
|
||||||
<thead className="border-b bg-muted/30">
|
return (
|
||||||
<tr>
|
<Card
|
||||||
<th className="p-3 text-left font-medium">{t("colPeriod")}</th>
|
key={weekday}
|
||||||
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
|
className={cn(
|
||||||
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
|
isToday && "border-primary ring-1 ring-primary/30 shadow-sm",
|
||||||
<th className="p-3 text-left font-medium">{t("colClassroom")}</th>
|
)}
|
||||||
<th className="p-3 text-left font-medium">{t("colTime")}</th>
|
>
|
||||||
</tr>
|
<CardHeader className="pb-3">
|
||||||
</thead>
|
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||||
<tbody className="divide-y">
|
<span>{label}</span>
|
||||||
{items.map((item, idx) => (
|
{isToday ? (
|
||||||
<tr
|
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-primary-foreground">
|
||||||
key={`${item.weekday}-${item.period}-${idx}`}
|
{t("today")}
|
||||||
className="hover:bg-muted/30"
|
</span>
|
||||||
>
|
) : null}
|
||||||
<td className="p-3 font-mono text-xs">{item.period}</td>
|
</CardTitle>
|
||||||
<td className="p-3 font-medium">{item.subject}</td>
|
</CardHeader>
|
||||||
<td className="p-3 text-muted-foreground">{item.teacher}</td>
|
<CardContent>
|
||||||
<td className="p-3 text-muted-foreground">{item.classroom}</td>
|
{dayItems.length === 0 ? (
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{item.time}
|
{t("noClasses")}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
) : (
|
||||||
))}
|
<ul className="space-y-3">
|
||||||
</tbody>
|
{dayItems.map((item, i) => (
|
||||||
</table>
|
<ScheduleListItem
|
||||||
</div>
|
key={`${item.weekday}-${item.period}-${i}`}
|
||||||
</section>
|
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";
|
"use client";
|
||||||
|
|
||||||
// @contract-pending:studentTextbookChapters schema 未实现,全 MSW 兜底
|
// @contract-pending:studentTextbookChapters schema 未实现,全 MSW 兜底
|
||||||
|
// @contract-pending student-grade-filter:理想做法是后端 BFF 在 studentTextbookChapters
|
||||||
|
// resolver 中按学生年级校验;当前在前端用 useStudentClasses().grade 兜底校验。
|
||||||
/**
|
/**
|
||||||
* 学生教材章节阅读器 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
* 学生教材章节阅读器 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - studentTextbookChapters(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentTextbookChapters(id) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - 学生年级:来自 studentClasses(首个活跃班级的 grade),前端二次校验
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):
|
* 三态规范(§11.3 DoD):
|
||||||
@@ -15,14 +18,22 @@
|
|||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { BookOpen } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BookOpen,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useStudentClasses,
|
||||||
useStudentTextbookChapters,
|
useStudentTextbookChapters,
|
||||||
type StudentTextbookChapter,
|
type StudentTextbookChapter,
|
||||||
|
type StudentTextbookChapterContent,
|
||||||
|
type StudentTextbookChapterSection,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import {
|
import {
|
||||||
@@ -42,16 +53,38 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
|||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const textbookId = params?.id ?? "";
|
const textbookId = params?.id ?? "";
|
||||||
|
|
||||||
|
// 学生年级:从首个活跃班级获取(@contract-pending student-grade-filter)
|
||||||
|
const {
|
||||||
|
data: classes,
|
||||||
|
loading: loadingClasses,
|
||||||
|
error: classesError,
|
||||||
|
} = useStudentClasses();
|
||||||
|
const studentGrade = classes?.find((c) => c.isActive)?.grade ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useStudentTextbookChapters(textbookId);
|
const {
|
||||||
|
data,
|
||||||
|
loading: loadingChapters,
|
||||||
|
error,
|
||||||
|
} = useStudentTextbookChapters(textbookId);
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
|
||||||
|
const loading = loadingClasses || loadingChapters;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
notify.error(tCommon("error.loadFailed", { message: String(error) }));
|
||||||
}
|
}
|
||||||
}, [error, tCommon]);
|
}, [error, tCommon]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (classesError) {
|
||||||
|
notify.error(
|
||||||
|
tCommon("error.loadFailed", { message: String(classesError) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [classesError, tCommon]);
|
||||||
|
|
||||||
// 选中首个章节
|
// 选中首个章节
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data.chapters.length > 0 && !selectedId) {
|
if (data && data.chapters.length > 0 && !selectedId) {
|
||||||
@@ -62,6 +95,12 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [data, selectedId]);
|
}, [data, selectedId]);
|
||||||
|
|
||||||
|
// 年级不匹配警告(前端兜底校验,理想:BFF resolver 校验)
|
||||||
|
const gradeMismatch =
|
||||||
|
!loadingClasses && !classesError && studentGrade && data
|
||||||
|
? data.grade !== studentGrade
|
||||||
|
: false;
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -95,6 +134,9 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
|||||||
{data ? (
|
{data ? (
|
||||||
<ChaptersReader
|
<ChaptersReader
|
||||||
chapters={data.chapters}
|
chapters={data.chapters}
|
||||||
|
subject={data.subject}
|
||||||
|
grade={data.grade}
|
||||||
|
gradeMismatch={gradeMismatch}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={setSelectedId}
|
||||||
/>
|
/>
|
||||||
@@ -104,19 +146,58 @@ export function StudentTextbookChaptersClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 章节阅读器(左侧章节列表 + 右侧阅读区)。
|
* 章节阅读器(顶栏 + 左侧章节列表 + 右侧阅读区)。
|
||||||
*/
|
*/
|
||||||
function ChaptersReader({
|
function ChaptersReader({
|
||||||
chapters,
|
chapters,
|
||||||
|
subject,
|
||||||
|
grade,
|
||||||
|
gradeMismatch,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
chapters: StudentTextbookChapter[];
|
chapters: StudentTextbookChapter[];
|
||||||
|
subject: string;
|
||||||
|
grade: string;
|
||||||
|
gradeMismatch: boolean;
|
||||||
selectedId: string;
|
selectedId: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("studentDomain.textbooks.chapters");
|
const t = useTranslations("studentDomain.textbooks.chapters");
|
||||||
const selected = chapters.find((ch) => ch.id === selectedId) ?? chapters[0];
|
|
||||||
|
const selectedIndex = useMemo(
|
||||||
|
() => chapters.findIndex((ch) => ch.id === selectedId),
|
||||||
|
[chapters, selectedId],
|
||||||
|
);
|
||||||
|
const selected = selectedIndex >= 0 ? chapters[selectedIndex] : chapters[0];
|
||||||
|
const safeIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||||
|
const hasPrev = safeIndex > 0;
|
||||||
|
const hasNext = safeIndex < chapters.length - 1;
|
||||||
|
|
||||||
|
const handlePrev = (): void => {
|
||||||
|
if (!hasPrev) return;
|
||||||
|
const prev = chapters[safeIndex - 1];
|
||||||
|
if (prev) onSelect(prev.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
if (!hasNext) return;
|
||||||
|
const next = chapters[safeIndex + 1];
|
||||||
|
if (next) onSelect(next.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ReaderHeader
|
||||||
|
subject={subject}
|
||||||
|
grade={grade}
|
||||||
|
gradeMismatch={gradeMismatch}
|
||||||
|
/>
|
||||||
|
<EmptyState icon={BookOpen} title={t("emptyChapters")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return (
|
return (
|
||||||
@@ -132,11 +213,14 @@ function ChaptersReader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
|
<div className="flex flex-col gap-4">
|
||||||
<DetailSection title={t("sectionChapters")}>
|
<ReaderHeader
|
||||||
{chapters.length === 0 ? (
|
subject={subject}
|
||||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
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">
|
<ul className="space-y-1">
|
||||||
{chapters.map((ch, idx) => (
|
{chapters.map((ch, idx) => (
|
||||||
<li key={ch.id}>
|
<li key={ch.id}>
|
||||||
@@ -144,29 +228,160 @@ function ChaptersReader({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(ch.id)}
|
onClick={() => onSelect(ch.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
|
"flex w-full items-start gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
ch.id === selected.id
|
ch.id === selected.id
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted",
|
: "hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs opacity-70">
|
<span className="font-mono text-xs opacity-70">
|
||||||
{idx + 1}.
|
{t("chapterLabel", { n: idx + 1 })}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{ch.title}</span>
|
<span className="truncate">{ch.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
</DetailSection>
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection title={t("sectionContent")}>
|
<DetailSection
|
||||||
<h3 className="text-lg font-semibold">{selected.title}</h3>
|
title={t("sectionContent")}
|
||||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
actions={
|
||||||
{selected.content}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<button
|
||||||
</DetailSection>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// @contract-pending:studentTextbooks schema 未实现,全 MSW 兜底
|
// @contract-pending:studentTextbooks schema 未实现,全 MSW 兜底
|
||||||
|
// @contract-pending student-grade-filter:理想做法是后端 BFF 在 studentTextbooks
|
||||||
|
// resolver 中按学生年级过滤;当前在前端用 useStudentClasses().grade 兜底过滤。
|
||||||
/**
|
/**
|
||||||
* 学生教材列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
* 学生教材列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - studentTextbooks(q, subject, grade) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - studentTextbooks(q, subject, grade) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - 学生年级:来自 studentClasses(首个活跃班级的 grade),前端二次过滤
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||||
*
|
*
|
||||||
* URL 状态:?q=&subject=&grade=
|
* URL 状态:?q=&subject=&grade=
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||||
|
* - 区分有/无筛选的空态
|
||||||
|
* - 学生年级未设置时显示提示
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen, RotateCcw, SearchX } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useStudentTextbooks, type StudentTextbook } from "@/lib/api";
|
import {
|
||||||
|
useStudentClasses,
|
||||||
|
useStudentTextbooks,
|
||||||
|
type StudentTextbook,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import {
|
import {
|
||||||
@@ -28,15 +38,22 @@ import {
|
|||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
|
||||||
const SUBJECT_OPTIONS = ["语文", "数学", "英语", "物理", "化学", "生物"];
|
const SUBJECT_OPTION_KEYS = [
|
||||||
const GRADE_OPTIONS = [
|
"chinese",
|
||||||
"一年级",
|
"math",
|
||||||
"二年级",
|
"english",
|
||||||
"三年级",
|
"physics",
|
||||||
"七年级",
|
"chemistry",
|
||||||
"八年级",
|
"biology",
|
||||||
"九年级",
|
] as const;
|
||||||
];
|
const GRADE_OPTION_KEYS = [
|
||||||
|
"grade1",
|
||||||
|
"grade2",
|
||||||
|
"grade3",
|
||||||
|
"grade7",
|
||||||
|
"grade8",
|
||||||
|
"grade9",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -53,14 +70,36 @@ export function StudentTextbooksListClient(): React.ReactElement {
|
|||||||
const subject = searchParams.get("subject") ?? "";
|
const subject = searchParams.get("subject") ?? "";
|
||||||
const grade = searchParams.get("grade") ?? "";
|
const grade = searchParams.get("grade") ?? "";
|
||||||
|
|
||||||
|
// 学生年级:从首个活跃班级获取(@contract-pending student-grade-filter)
|
||||||
|
// 学生信息 hook 未单独提供 grade,借用 useStudentClasses 兜底。
|
||||||
|
const {
|
||||||
|
data: classes,
|
||||||
|
loading: loadingClasses,
|
||||||
|
error: classesError,
|
||||||
|
} = useStudentClasses();
|
||||||
|
const studentGrade = classes?.find((c) => c.isActive)?.grade ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useStudentTextbooks({
|
const {
|
||||||
|
data,
|
||||||
|
loading: loadingTextbooks,
|
||||||
|
error,
|
||||||
|
} = useStudentTextbooks({
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
subject: subject || undefined,
|
subject: subject || undefined,
|
||||||
grade: grade || undefined,
|
grade: grade || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = data ?? [];
|
// 前端按学生年级强制过滤(理想:BFF resolver 过滤)
|
||||||
|
const rawItems = data ?? [];
|
||||||
|
const items = studentGrade
|
||||||
|
? rawItems.filter((tb) => tb.grade === studentGrade)
|
||||||
|
: rawItems;
|
||||||
|
|
||||||
|
const loading = loadingClasses || loadingTextbooks;
|
||||||
|
const hasActiveFilters = Boolean(q || subject || grade);
|
||||||
|
// 年级未设置:班级已加载、无错误、且无活跃班级含 grade
|
||||||
|
const gradeNotSet = !loadingClasses && !classesError && !studentGrade;
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string): void => {
|
const updateQuery = (key: string, value: string): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -74,6 +113,12 @@ export function StudentTextbooksListClient(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = (): void => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push("/shell/student/textbooks");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -83,7 +128,34 @@ export function StudentTextbooksListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const emptyNode = (
|
// 年级未设置:单独提示,避免显示不属于学生年级的教材
|
||||||
|
if (gradeNotSet) {
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title={t("title")}
|
||||||
|
description={t("description")}
|
||||||
|
icon={<BookOpen className="size-6" />}
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("gradeNotSetTitle")}
|
||||||
|
description={t("gradeNotSetDescription")}
|
||||||
|
/>
|
||||||
|
</ListPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyNode = hasActiveFilters ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={t("emptyFilteredTitle")}
|
||||||
|
description={t("emptyFilteredDescription")}
|
||||||
|
action={{
|
||||||
|
label: t("clearFilters"),
|
||||||
|
onClick: clearAllFilters,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title={t("emptyTitle")}
|
title={t("emptyTitle")}
|
||||||
@@ -94,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 (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
icon={<BookOpen className="size-6" />}
|
icon={<BookOpen className="size-6" />}
|
||||||
filters={
|
filters={filtersNode}
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
loadingNode={<ListPageSkeleton rows={5} />}
|
||||||
empty={items.length === 0 && !loading}
|
empty={items.length === 0 && !loading}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
*
|
*
|
||||||
* 数据契约(@contract-pending 全 MSW):
|
* 数据契约(@contract-pending 全 MSW):
|
||||||
* - aiAssistSessions(teacherId) ❌ → MSW 兜底
|
* - aiAssistSessions(teacherId) ❌ → MSW 兜底
|
||||||
* - sendAiMessage(input) mutation ❌ → MSW 兜底
|
* - 聊天流式响应 → /api/ai/chat/stream(AiChatPanel 内部 useAiChatStream,SSE)
|
||||||
*
|
*
|
||||||
* 布局(WorkbenchPageShell):
|
* 布局(WorkbenchPageShell):
|
||||||
* - left:会话列表
|
* - left:会话列表(MSW 兜底)
|
||||||
* - center:聊天区域(消息历史 + 输入框)
|
* - center:AI 聊天面板(AiChatPanel widget 变体,SSE 流式逐 token 渲染)
|
||||||
* - right:上下文信息(统计)
|
* - right:上下文信息(统计)
|
||||||
*
|
*
|
||||||
* 三态规范(§11.3 DoD):
|
* 三态规范(§11.3 DoD):
|
||||||
@@ -19,12 +19,11 @@
|
|||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 B2 末 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { Bot, Send } from "lucide-react";
|
import { Bot } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useAiAssistSessions, useSendAiMessage } from "@/lib/api";
|
import { useAiAssistSessions } from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
WorkbenchPageShell,
|
WorkbenchPageShell,
|
||||||
@@ -35,13 +34,7 @@ import {
|
|||||||
formatAiDate,
|
formatAiDate,
|
||||||
totalMessageCount,
|
totalMessageCount,
|
||||||
} from "@/features/teacher/ai/transformations";
|
} from "@/features/teacher/ai/transformations";
|
||||||
|
import { AiChatPanel } from "@/features/teacher/ai/components/ai-chat-panel";
|
||||||
interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiAssistClient(): React.ReactElement {
|
export function AiAssistClient(): React.ReactElement {
|
||||||
const t = useTranslations("ai");
|
const t = useTranslations("ai");
|
||||||
@@ -49,13 +42,10 @@ export function AiAssistClient(): React.ReactElement {
|
|||||||
|
|
||||||
const teacherId = "dev-teacher-001";
|
const teacherId = "dev-teacher-001";
|
||||||
const { data: sessions, loading, error } = useAiAssistSessions(teacherId);
|
const { data: sessions, loading, error } = useAiAssistSessions(teacherId);
|
||||||
const sendMessage = useSendAiMessage();
|
|
||||||
|
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
|
|
||||||
const sessionList = useMemo(() => {
|
const sessionList = useMemo(() => {
|
||||||
const items = sessions ?? [];
|
const items = sessions ?? [];
|
||||||
@@ -67,34 +57,6 @@ export function AiAssistClient(): React.ReactElement {
|
|||||||
|
|
||||||
const totalCount = totalMessageCount(sessions ?? []);
|
const totalCount = totalMessageCount(sessions ?? []);
|
||||||
|
|
||||||
const handleSend = async (): Promise<void> => {
|
|
||||||
const content = input.trim();
|
|
||||||
if (!content) return;
|
|
||||||
setInput("");
|
|
||||||
const userMsg: ChatMessage = {
|
|
||||||
id: `user-${Date.now()}`,
|
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, userMsg]);
|
|
||||||
try {
|
|
||||||
const result = await sendMessage.run({
|
|
||||||
sessionId: selectedSessionId ?? undefined,
|
|
||||||
message: content,
|
|
||||||
});
|
|
||||||
const aiMsg: ChatMessage = {
|
|
||||||
id: result.messageId,
|
|
||||||
role: "assistant",
|
|
||||||
content: result.reply,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, aiMsg]);
|
|
||||||
} catch (err) {
|
|
||||||
notify.error(t("assist.sendFailed", { message: String(err) }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -152,59 +114,15 @@ export function AiAssistClient(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
center={
|
center={
|
||||||
<WorkbenchPanel title={t("assist.chatTitle")}>
|
<WorkbenchPanel title={t("assist.chatTitle")}>
|
||||||
<div className="flex h-full flex-col">
|
<AiChatPanel
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto pr-2">
|
variant="widget"
|
||||||
{messages.length === 0 ? (
|
placeholder={t("assist.inputPlaceholder")}
|
||||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
suggestedPrompts={[
|
||||||
<div>
|
t("chat.suggestedPrompts.teacher.0"),
|
||||||
<Bot className="mx-auto size-12 opacity-50" />
|
t("chat.suggestedPrompts.teacher.1"),
|
||||||
<p className="mt-3 text-sm">{t("assist.emptyChat")}</p>
|
t("chat.suggestedPrompts.teacher.2"),
|
||||||
<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>
|
|
||||||
</WorkbenchPanel>
|
</WorkbenchPanel>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
|
|||||||
@@ -27,12 +27,7 @@ import {
|
|||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import {
|
import { AttendanceRecordList } from "./components";
|
||||||
attendanceStatusToBadgeClass,
|
|
||||||
formatAttendanceDate,
|
|
||||||
formatAttendanceDay,
|
|
||||||
formatAttendanceStatus,
|
|
||||||
} from "@/features/teacher/attendance/transformations";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -157,85 +152,7 @@ export function AttendanceListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AttendanceTable items={filteredItems} />
|
<AttendanceRecordList records={filteredItems} />
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
|
||||||
*/
|
|
||||||
function AttendanceTable({
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
items: AttendanceRecord[];
|
|
||||||
}): React.ReactElement {
|
|
||||||
const t = useTranslations("attendance");
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b bg-muted/30">
|
|
||||||
<tr>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colStudentName")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colClassName")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colDate")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colRemark")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colRecordedBy")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colUpdatedAt")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{items.map((r) => (
|
|
||||||
<tr key={r.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">{r.studentName}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">{r.className}</td>
|
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
|
||||||
{formatAttendanceDay(r.date)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<AttendanceStatusBadge status={r.status} />
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-xs text-muted-foreground">
|
|
||||||
{r.remark ?? "-"}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
|
||||||
{r.recordedBy}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
|
||||||
{formatAttendanceDate(r.updatedAt)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 考勤状态徽章(按状态色阶展示)。
|
|
||||||
*/
|
|
||||||
function AttendanceStatusBadge({
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
status: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const label = formatAttendanceStatus(status);
|
|
||||||
const cls = attendanceStatusToBadgeClass(status);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ import {
|
|||||||
progressToColorClass,
|
progressToColorClass,
|
||||||
} from "@/features/teacher/course-plans/transformations";
|
} from "@/features/teacher/course-plans/transformations";
|
||||||
|
|
||||||
|
// P2 迁移:周计划编辑器 / 周计划表格行 / 模板选择器(供详情页配套使用)
|
||||||
|
export { CoursePlanItemEditor } from "./components/course-plan-item-editor";
|
||||||
|
export type { CoursePlanItemPayload } from "./components/course-plan-item-editor";
|
||||||
|
export { SortableWeekRow } from "./components/sortable-week-row";
|
||||||
|
export { TemplatePickerDialog } from "./components/template-picker-dialog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import {
|
|||||||
formatCoursePlanStatus,
|
formatCoursePlanStatus,
|
||||||
} from "@/features/teacher/course-plans/transformations";
|
} from "@/features/teacher/course-plans/transformations";
|
||||||
|
|
||||||
|
// P2 迁移:模板选择器(供列表页"从模板创建"入口配套使用)
|
||||||
|
export { TemplatePickerDialog } from "./components/template-picker-dialog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||||
|
|||||||
@@ -37,6 +37,52 @@ import {
|
|||||||
formatStudentCount,
|
formatStudentCount,
|
||||||
masteryToColorClass,
|
masteryToColorClass,
|
||||||
} from "@/features/teacher/diagnostic/transformations";
|
} from "@/features/teacher/diagnostic/transformations";
|
||||||
|
import { ClassDiagnosticView } from "./components/class-diagnostic-view";
|
||||||
|
import { DiagnosticServiceProvider } from "./services/diagnostic-service-context";
|
||||||
|
import type { ClassMasterySummary } from "./components/types";
|
||||||
|
|
||||||
|
// P2 迁移:诊断模块组件(雷达图、报告列表、置信度工具)
|
||||||
|
export { MasteryRadarChart } from "./components/mastery-radar-chart";
|
||||||
|
export { ReportList } from "./components/report-list";
|
||||||
|
export {
|
||||||
|
getConfidenceLevel,
|
||||||
|
confidenceBadgeVariant,
|
||||||
|
type ConfidenceLevel,
|
||||||
|
} from "./components/confidence-utils";
|
||||||
|
export type {
|
||||||
|
DiagnosticReportWithDetails,
|
||||||
|
MasteryRadarPoint,
|
||||||
|
ReportStatus,
|
||||||
|
ReportType,
|
||||||
|
} from "./components/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将诊断报告详情映射为 ClassMasterySummary 供 ClassDiagnosticView 消费。
|
||||||
|
* 弱项知识点映射为 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> 中。
|
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
@@ -95,6 +141,13 @@ export function DiagnosticClassDetailClient(): React.ReactElement {
|
|||||||
{data ? (
|
{data ? (
|
||||||
<RecommendationsSection recommendations={data.recommendations} />
|
<RecommendationsSection recommendations={data.recommendations} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{data ? (
|
||||||
|
<DiagnosticServiceProvider>
|
||||||
|
<ClassDiagnosticView
|
||||||
|
summary={buildClassMasterySummary(data, classId)}
|
||||||
|
/>
|
||||||
|
</DiagnosticServiceProvider>
|
||||||
|
) : null}
|
||||||
</DetailPageShell>
|
</DetailPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,104 @@ import {
|
|||||||
masteryLevelToBadgeClass,
|
masteryLevelToBadgeClass,
|
||||||
} from "@/features/teacher/diagnostic/student-diagnostic-transformations";
|
} from "@/features/teacher/diagnostic/student-diagnostic-transformations";
|
||||||
import { formatDiagnosticDate } from "@/features/teacher/diagnostic/transformations";
|
import { formatDiagnosticDate } from "@/features/teacher/diagnostic/transformations";
|
||||||
|
import { StudentDiagnosticView } from "./components/student-diagnostic-view";
|
||||||
|
import type {
|
||||||
|
DiagnosticReportWithDetails,
|
||||||
|
MasteryWithKnowledgePoint,
|
||||||
|
ReportStatus,
|
||||||
|
ReportType,
|
||||||
|
StudentMasterySummary,
|
||||||
|
} from "./components/types";
|
||||||
|
|
||||||
|
// P2 迁移:学生诊断详情页可引用雷达图组件(共享导出)
|
||||||
|
export { MasteryRadarChart } from "./components/mastery-radar-chart";
|
||||||
|
export type { MasteryRadarPoint } from "./components/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 portal-shell 学生诊断报告状态映射为 DiagnosticReportWithDetails 的小写状态。
|
||||||
|
* portal-shell schema 使用 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> 中。
|
* 学生诊断详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
@@ -101,6 +199,13 @@ export function StudentDiagnosticClient(): React.ReactElement {
|
|||||||
<KnowledgePointsSection knowledgePoints={data.knowledge_points} />
|
<KnowledgePointsSection knowledgePoints={data.knowledge_points} />
|
||||||
) : null}
|
) : null}
|
||||||
{data ? <StudentReportsSection reports={data.reports} /> : null}
|
{data ? <StudentReportsSection reports={data.reports} /> : null}
|
||||||
|
{data ? (
|
||||||
|
<StudentDiagnosticView
|
||||||
|
summary={buildStudentMasterySummary(data)}
|
||||||
|
reports={buildStudentReports(data.reports, data.student_name)}
|
||||||
|
role="teacher"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</DetailPageShell>
|
</DetailPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useTransition, useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useCreateElective, type CreateElectiveInput } from "@/lib/api";
|
import { useCreateElective, type CreateElectiveInput } from "@/lib/api";
|
||||||
|
import { ElectivePageLayout } from "./components";
|
||||||
import { FormPageShell } from "@/shared/components/page-templates";
|
import { FormPageShell } from "@/shared/components/page-templates";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
@@ -113,101 +114,105 @@ function ElectiveFormInner({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormPageShell
|
<ElectivePageLayout header={null} className="p-0 space-y-0">
|
||||||
title={t("create.title")}
|
<FormPageShell
|
||||||
description={t("create.description")}
|
title={t("create.title")}
|
||||||
icon={<BookMarked className="size-6" />}
|
description={t("create.description")}
|
||||||
backHref="/shell/teacher/elective"
|
icon={<BookMarked className="size-6" />}
|
||||||
onSubmit={handleFormSubmit}
|
backHref="/shell/teacher/elective"
|
||||||
submitting={submitting}
|
onSubmit={handleFormSubmit}
|
||||||
submitLabel={t("create.submit")}
|
submitting={submitting}
|
||||||
cancelLabel={tCommon("button.cancel")}
|
submitLabel={t("create.submit")}
|
||||||
errorSummary={
|
cancelLabel={tCommon("button.cancel")}
|
||||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
errorSummary={
|
||||||
}
|
error ? (
|
||||||
>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
<FormField label={t("create.nameLabel")} required>
|
) : undefined
|
||||||
<input
|
}
|
||||||
type="text"
|
>
|
||||||
value={name}
|
<FormField label={t("create.nameLabel")} required>
|
||||||
onChange={(e) => setName(e.target.value)}
|
<input
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
type="text"
|
||||||
placeholder={t("create.namePlaceholder")}
|
value={name}
|
||||||
required
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
</FormField>
|
placeholder={t("create.namePlaceholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.descriptionLabel")}>
|
<FormField label={t("create.descriptionLabel")}>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
placeholder={t("create.descriptionPlaceholder")}
|
placeholder={t("create.descriptionPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.subjectLabel")} required>
|
<FormField label={t("create.subjectLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
placeholder={t("create.subjectPlaceholder")}
|
placeholder={t("create.subjectPlaceholder")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.gradeLevelLabel")} required>
|
<FormField label={t("create.gradeLevelLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={gradeLevel}
|
value={gradeLevel}
|
||||||
onChange={(e) => setGradeLevel(e.target.value)}
|
onChange={(e) => setGradeLevel(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
placeholder={t("create.gradeLevelPlaceholder")}
|
placeholder={t("create.gradeLevelPlaceholder")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.semesterLabel")} required>
|
<FormField label={t("create.semesterLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={semester}
|
value={semester}
|
||||||
onChange={(e) => setSemester(e.target.value)}
|
onChange={(e) => setSemester(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
placeholder={t("create.semesterPlaceholder")}
|
placeholder={t("create.semesterPlaceholder")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("create.capacityHint")}
|
{t("create.contractPending")}
|
||||||
</p>
|
</p>
|
||||||
</FormField>
|
</FormPageShell>
|
||||||
|
</ElectivePageLayout>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
useUpdateElective,
|
useUpdateElective,
|
||||||
type UpdateElectiveInput,
|
type UpdateElectiveInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { ElectivePageLayout } from "./components";
|
||||||
import {
|
import {
|
||||||
FormPageShell,
|
FormPageShell,
|
||||||
FormPageSkeleton,
|
FormPageSkeleton,
|
||||||
@@ -141,112 +142,114 @@ export function ElectiveEditClient(): React.ReactElement {
|
|||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormPageShell
|
<ElectivePageLayout header={null} className="p-0 space-y-0">
|
||||||
title={t("edit.title")}
|
<FormPageShell
|
||||||
description={t("edit.description")}
|
title={t("edit.title")}
|
||||||
icon={<BookMarked className="size-6" />}
|
description={t("edit.description")}
|
||||||
backHref="/shell/teacher/elective"
|
icon={<BookMarked className="size-6" />}
|
||||||
onSubmit={handleFormSubmit}
|
backHref="/shell/teacher/elective"
|
||||||
submitting={submitting}
|
onSubmit={handleFormSubmit}
|
||||||
submitLabel={t("edit.save")}
|
submitting={submitting}
|
||||||
cancelLabel={tCommon("button.cancel")}
|
submitLabel={t("edit.save")}
|
||||||
errorSummary={
|
cancelLabel={tCommon("button.cancel")}
|
||||||
formError ? (
|
errorSummary={
|
||||||
<p className="text-sm text-destructive">{formError}</p>
|
formError ? (
|
||||||
) : undefined
|
<p className="text-sm text-destructive">{formError}</p>
|
||||||
}
|
) : undefined
|
||||||
loading={loading}
|
}
|
||||||
loadingNode={errorNode ?? emptyNode ?? <FormPageSkeleton />}
|
loading={loading}
|
||||||
>
|
loadingNode={errorNode ?? emptyNode ?? <FormPageSkeleton />}
|
||||||
{data && initialized ? (
|
>
|
||||||
<>
|
{data && initialized ? (
|
||||||
<FormField label={t("create.nameLabel")} required>
|
<>
|
||||||
<input
|
<FormField label={t("create.nameLabel")} required>
|
||||||
type="text"
|
<input
|
||||||
value={name}
|
type="text"
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
/>
|
required
|
||||||
</FormField>
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.descriptionLabel")}>
|
<FormField label={t("create.descriptionLabel")}>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.subjectLabel")} required>
|
<FormField label={t("create.subjectLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.gradeLevelLabel")} required>
|
<FormField label={t("create.gradeLevelLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={gradeLevel}
|
value={gradeLevel}
|
||||||
onChange={(e) => setGradeLevel(e.target.value)}
|
onChange={(e) => setGradeLevel(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.semesterLabel")}>
|
<FormField label={t("create.semesterLabel")}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={semester}
|
value={semester}
|
||||||
onChange={(e) => setSemester(e.target.value)}
|
onChange={(e) => setSemester(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.capacityLabel")} required>
|
<FormField label={t("create.capacityLabel")} required>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={capacity}
|
value={capacity}
|
||||||
onChange={(e) => setCapacity(e.target.value)}
|
onChange={(e) => setCapacity(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("create.teacherIdLabel")}>
|
<FormField label={t("create.teacherIdLabel")}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={teacherId}
|
value={teacherId}
|
||||||
onChange={(e) => setTeacherId(e.target.value)}
|
onChange={(e) => setTeacherId(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={t("edit.statusLabel")}>
|
<FormField label={t("edit.statusLabel")}>
|
||||||
<select
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
>
|
>
|
||||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||||
<option value="OPEN">{t("list.statusOpen")}</option>
|
<option value="OPEN">{t("list.statusOpen")}</option>
|
||||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||||
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("edit.contractPending")}
|
{t("edit.contractPending")}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FormPageShell>
|
</FormPageShell>
|
||||||
|
</ElectivePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
formatElectiveStatus,
|
formatElectiveStatus,
|
||||||
formatEnrollmentCount,
|
formatEnrollmentCount,
|
||||||
} from "@/features/teacher/elective/transformations";
|
} from "@/features/teacher/elective/transformations";
|
||||||
|
import { ElectivePageLayout } from "./components";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -130,7 +131,9 @@ export function ElectiveListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ElectiveTable items={filteredItems} />
|
<ElectivePageLayout header={null} className="space-y-4 p-0">
|
||||||
|
<ElectiveTable items={filteredItems} />
|
||||||
|
</ElectivePageLayout>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,68 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Elective, ElectiveListItem, ElectiveStatus } from "@/lib/api";
|
import type { Elective, ElectiveListItem, ElectiveStatus } from "@/lib/api";
|
||||||
|
import type { ElectiveCourseStatus, ElectiveCourseWithDetails } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 portal-shell 的 ElectiveStatus(大写枚举)映射为迁移组件的 ElectiveCourseStatus(小写枚举)。
|
||||||
|
* ARCHIVED → cancelled(语义最接近:不再活跃)。
|
||||||
|
*/
|
||||||
|
export function mapElectiveStatus(
|
||||||
|
status: ElectiveStatus,
|
||||||
|
): ElectiveCourseStatus {
|
||||||
|
switch (status) {
|
||||||
|
case "DRAFT":
|
||||||
|
return "draft";
|
||||||
|
case "OPEN":
|
||||||
|
return "open";
|
||||||
|
case "CLOSED":
|
||||||
|
return "closed";
|
||||||
|
case "ARCHIVED":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "draft";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 portal-shell 的 Elective 适配为迁移组件使用的 ElectiveCourseWithDetails。
|
||||||
|
*
|
||||||
|
* 字段映射说明:
|
||||||
|
* - subject → subjectId / 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> = {
|
export const ELECTIVE_STATUS_LABEL: Record<string, string> = {
|
||||||
|
|||||||
@@ -26,12 +26,7 @@ import {
|
|||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import {
|
import { ExamDataTable } from "./components/exam-data-table";
|
||||||
formatExamDate,
|
|
||||||
formatExamStatus,
|
|
||||||
formatDuration,
|
|
||||||
parseTotalScore,
|
|
||||||
} from "@/features/teacher/exams/transformations";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -130,97 +125,7 @@ export function ExamsListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExamsTable items={filteredItems} />
|
<ExamDataTable data={filteredItems} />
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
|
||||||
*/
|
|
||||||
function ExamsTable({ items }: { items: ExamListItem[] }): React.ReactElement {
|
|
||||||
const t = useTranslations("exams");
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b bg-muted/30">
|
|
||||||
<tr>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colExamDate")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colDuration")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colTotalScore")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-right font-medium">
|
|
||||||
{t("list.colActions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{items.map((exam) => (
|
|
||||||
<tr key={exam.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-3">
|
|
||||||
<Link
|
|
||||||
href={`/shell/teacher/exams/${exam.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{exam.title}
|
|
||||||
</Link>
|
|
||||||
{exam.description ? (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{exam.description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<ExamStatusBadge status={exam.status} />
|
|
||||||
</td>
|
|
||||||
<td className="p-3 font-mono text-xs">
|
|
||||||
{formatExamDate(exam.examDate)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-xs">{formatDuration(exam.duration)}</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{parseTotalScore(exam.totalScore)} {t("list.unitScore")}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
<Link
|
|
||||||
href={`/shell/teacher/exams/${exam.id}`}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{t("list.viewDetail")}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 考试状态徽章(按状态色阶展示)。
|
|
||||||
*/
|
|
||||||
function ExamStatusBadge({ status }: { status: string }): React.ReactElement {
|
|
||||||
const label = formatExamStatus(status);
|
|
||||||
const cls =
|
|
||||||
status === "DRAFT"
|
|
||||||
? "bg-muted text-muted-foreground"
|
|
||||||
: status === "PUBLISHED" || status === "IN_PROGRESS"
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: status === "SCORED"
|
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "bg-muted text-muted-foreground";
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ import {
|
|||||||
formatExamDate,
|
formatExamDate,
|
||||||
} from "@/features/teacher/exams/transformations";
|
} from "@/features/teacher/exams/transformations";
|
||||||
|
|
||||||
|
// P2 迁移:反作弊与考试模式配置组件(监考工作台配套)
|
||||||
|
export { AntiCheatMonitor } from "./components/anti-cheat-monitor";
|
||||||
|
export { ExamModeConfig } from "./components/exam-mode-config";
|
||||||
|
export type {
|
||||||
|
ExamMode,
|
||||||
|
ExamModeConfigFieldValues,
|
||||||
|
} from "./components/exam-mode-config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监考工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 监考工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -246,3 +246,86 @@ export function toGradeListItem(
|
|||||||
gradedBy: grade.gradedBy,
|
gradedBy: grade.gradedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 迁移自 CICD src/modules/grades/lib/grade-utils.ts ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely convert an unknown value to a finite number.
|
||||||
|
* Returns 0 when the value is not a finite number.
|
||||||
|
*
|
||||||
|
* Used to normalize numeric columns returned by data layer (which may be
|
||||||
|
* string | number depending on the source) into plain numbers.
|
||||||
|
*/
|
||||||
|
export function toNumber(v: unknown): number {
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw score to a 0-100 scale based on its full score.
|
||||||
|
* Returns 0 when fullScore is non-positive. Result is rounded to 2 decimals.
|
||||||
|
*/
|
||||||
|
export function normalize(score: number, fullScore: number): number {
|
||||||
|
if (fullScore <= 0) return 0;
|
||||||
|
return Math.round((score / fullScore) * 10000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算数组均值。空数组返回 0。
|
||||||
|
*/
|
||||||
|
export function calcAverage(values: ReadonlyArray<number>): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const sum = values.reduce((acc, v) => acc + v, 0);
|
||||||
|
return sum / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算数组中位数。空数组返回 0。
|
||||||
|
*/
|
||||||
|
export function calcMedian(values: ReadonlyArray<number>): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0
|
||||||
|
? sorted[mid]!
|
||||||
|
: (sorted[mid - 1]! + sorted[mid]!) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算数组标准差(总体标准差)。空数组返回 0。
|
||||||
|
*/
|
||||||
|
export function calcStdDev(values: ReadonlyArray<number>): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const avg = calcAverage(values);
|
||||||
|
const variance =
|
||||||
|
values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length;
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算及格率(score/fullScore >= 0.6)。空数组返回 0。
|
||||||
|
* 返回 0-1 的小数。
|
||||||
|
*/
|
||||||
|
export function calcPassRate(
|
||||||
|
scores: ReadonlyArray<{ score: number; fullScore: number }>,
|
||||||
|
): number {
|
||||||
|
if (scores.length === 0) return 0;
|
||||||
|
const passCount = scores.filter(
|
||||||
|
(s) => s.fullScore > 0 && s.score / s.fullScore >= 0.6,
|
||||||
|
).length;
|
||||||
|
return passCount / scores.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算优秀率(score/fullScore >= 0.85)。空数组返回 0。
|
||||||
|
* 返回 0-1 的小数。
|
||||||
|
*/
|
||||||
|
export function calcExcellentRate(
|
||||||
|
scores: ReadonlyArray<{ score: number; fullScore: number }>,
|
||||||
|
): number {
|
||||||
|
if (scores.length === 0) return 0;
|
||||||
|
const excellentCount = scores.filter(
|
||||||
|
(s) => s.fullScore > 0 && s.score / s.fullScore >= 0.85,
|
||||||
|
).length;
|
||||||
|
return excellentCount / scores.length;
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
isOverdue,
|
isOverdue,
|
||||||
submissionStatusToBadgeClass,
|
submissionStatusToBadgeClass,
|
||||||
} from "@/features/teacher/homework/transformations";
|
} from "@/features/teacher/homework/transformations";
|
||||||
|
import { ExcellentSubmissions } from "@/features/teacher/homework/components/excellent-submissions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
@@ -178,6 +179,8 @@ function HomeworkDetailBody({
|
|||||||
<DetailSection title={t("detail.sectionInlineGrade")}>
|
<DetailSection title={t("detail.sectionInlineGrade")}>
|
||||||
<InlineGradeForm homeworkId={homework.id} />
|
<InlineGradeForm homeworkId={homework.id} />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
<ExcellentSubmissions items={[]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
WorkbenchPageSkeleton,
|
WorkbenchPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import { ScanImageViewer } from "@/features/teacher/homework/components/scan-image-viewer";
|
||||||
|
import type { ScanImage } from "@/features/teacher/homework/components/scan-uploader";
|
||||||
import {
|
import {
|
||||||
confidenceToColorClass,
|
confidenceToColorClass,
|
||||||
formatConfidence,
|
formatConfidence,
|
||||||
@@ -114,7 +116,9 @@ export function ScanGradingClient(): React.ReactElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描图片预览面板(左栏)。
|
* 扫描图片预览面板(左栏)。
|
||||||
* 注:当前无真实扫描图片,使用占位符 + 提示。
|
* 注:当前无真实扫描图片 API,使用 ScanImageViewer 渲染空态。
|
||||||
|
* 后端补齐 scanAttachments 查询后,传入真实 images。
|
||||||
|
* @contract-pending:scanAttachments 查询契约待补齐。
|
||||||
*/
|
*/
|
||||||
function ScanPreviewPanel({
|
function ScanPreviewPanel({
|
||||||
submissionId,
|
submissionId,
|
||||||
@@ -122,19 +126,19 @@ function ScanPreviewPanel({
|
|||||||
submissionId: string;
|
submissionId: string;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("homework");
|
const t = useTranslations("homework");
|
||||||
|
// @contract-pending:后端补齐后改为 useSubmissionScans(submissionId) 拉取
|
||||||
|
const scans: ScanImage[] = [];
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3">
|
<div className="flex h-full flex-col gap-3">
|
||||||
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
|
<div className="min-h-[300px] flex-1">
|
||||||
<div className="text-center">
|
<ScanImageViewer images={scans} />
|
||||||
<ScanLine className="mx-auto size-12 text-muted-foreground" />
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{t("scan.imagePlaceholder", { id: submissionId })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("scan.imageContractPending")}
|
{t("scan.imageContractPending")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("scan.imagePlaceholder", { id: submissionId })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - 列表查询 practices(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - 列表查询 practices(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - 自适应练习会话查询/创建 ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#practice-list
|
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#practice-list
|
||||||
*
|
*
|
||||||
* URL 状态:?classId=xxx &subjectId=xxx &status=xxx &q=xxx
|
* URL 状态:?classId=xxx &subjectId=xxx &status=xxx &q=xxx
|
||||||
*
|
*
|
||||||
|
* 标签页结构:
|
||||||
|
* - 练习与作业:教师布置的练习列表(原有功能)
|
||||||
|
* - 自适应练习:发起自适应练习 + 查看本人练习历史(迁移自 CICD adaptive-practice)
|
||||||
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState 含 next action)
|
* 三态规范(§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
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
|
||||||
@@ -18,13 +23,29 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { usePractices, type PracticeListItem } from "@/lib/api";
|
import {
|
||||||
|
useAdaptivePracticeSessions,
|
||||||
|
useKnowledgePoints,
|
||||||
|
usePractices,
|
||||||
|
type PracticeListItem,
|
||||||
|
} from "@/lib/api";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import {
|
||||||
|
FilterBar,
|
||||||
|
FilterSearchInput,
|
||||||
|
} from "@/shared/components/ui/filter-bar";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/shared/components/ui/tabs";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import {
|
import {
|
||||||
formatAvgScore,
|
formatAvgScore,
|
||||||
formatPracticeDate,
|
formatPracticeDate,
|
||||||
@@ -33,6 +54,8 @@ import {
|
|||||||
isPracticeOverdue,
|
isPracticeOverdue,
|
||||||
practiceStatusToBadgeClass,
|
practiceStatusToBadgeClass,
|
||||||
} from "@/features/teacher/practice/transformations";
|
} from "@/features/teacher/practice/transformations";
|
||||||
|
import { PracticeHistory } from "./components/practice-history";
|
||||||
|
import { PracticeStarter } from "./components/practice-starter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -40,6 +63,36 @@ import {
|
|||||||
*/
|
*/
|
||||||
export function PracticeListClient(): React.ReactElement {
|
export function PracticeListClient(): React.ReactElement {
|
||||||
const t = useTranslations("practice");
|
const t = useTranslations("practice");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title={t("list.title")}
|
||||||
|
description={t("list.description")}
|
||||||
|
icon={<ClipboardList className="size-6" />}
|
||||||
|
>
|
||||||
|
<Tabs defaultValue="assignments" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="assignments">{t("tabs.assignments")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="adaptive">{t("tabs.adaptive")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="assignments" className="space-y-4">
|
||||||
|
<AssignmentsPanel />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="adaptive" className="space-y-6">
|
||||||
|
<AdaptivePracticePanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</ListPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 练习与作业面板:原有的列表 + 筛选逻辑。
|
||||||
|
*/
|
||||||
|
function AssignmentsPanel(): React.ReactElement {
|
||||||
|
const t = useTranslations("practice");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -93,51 +146,48 @@ export function PracticeListClient(): React.ReactElement {
|
|||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<>
|
||||||
title={t("list.title")}
|
<FilterBar variant="between">
|
||||||
description={t("list.description")}
|
<FilterSearchInput
|
||||||
icon={<ClipboardList className="size-6" />}
|
placeholder={t("list.searchPlaceholder")}
|
||||||
filters={
|
value={q}
|
||||||
<>
|
onChange={(v) => updateQuery("q", v)}
|
||||||
<FilterSearchInput
|
/>
|
||||||
placeholder={t("list.searchPlaceholder")}
|
<input
|
||||||
value={q}
|
type="text"
|
||||||
onChange={(v) => updateQuery("q", v)}
|
value={classId}
|
||||||
/>
|
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||||
<input
|
placeholder={t("list.classPlaceholder")}
|
||||||
type="text"
|
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
value={classId}
|
aria-label={t("list.classPlaceholder")}
|
||||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
/>
|
||||||
placeholder={t("list.classPlaceholder")}
|
<input
|
||||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
type="text"
|
||||||
aria-label={t("list.classPlaceholder")}
|
value={subjectId}
|
||||||
/>
|
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||||
<input
|
placeholder={t("list.subjectPlaceholder")}
|
||||||
type="text"
|
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
value={subjectId}
|
aria-label={t("list.subjectPlaceholder")}
|
||||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
/>
|
||||||
placeholder={t("list.subjectPlaceholder")}
|
<select
|
||||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
value={statusFilter}
|
||||||
aria-label={t("list.subjectPlaceholder")}
|
onChange={(e) => updateQuery("status", e.target.value)}
|
||||||
/>
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
<select
|
aria-label={t("list.statusFilter")}
|
||||||
value={statusFilter}
|
>
|
||||||
onChange={(e) => updateQuery("status", e.target.value)}
|
<option value="">{t("list.statusAll")}</option>
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||||
aria-label={t("list.statusFilter")}
|
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||||
>
|
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
|
||||||
<option value="">{t("list.statusAll")}</option>
|
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
</select>
|
||||||
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
</FilterBar>
|
||||||
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
|
|
||||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
{errorNode ? (
|
||||||
</select>
|
errorNode
|
||||||
</>
|
) : loading ? (
|
||||||
}
|
<ListPageSkeleton rows={5} />
|
||||||
loading={loading}
|
) : filteredItems.length === 0 ? (
|
||||||
loadingNode={<ListPageSkeleton rows={5} />}
|
|
||||||
empty={filteredItems.length === 0 && !loading}
|
|
||||||
emptyNode={
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t("list.emptyTitle")}
|
title={t("list.emptyTitle")}
|
||||||
description={t("list.emptyDescription")}
|
description={t("list.emptyDescription")}
|
||||||
@@ -146,16 +196,109 @@ export function PracticeListClient(): React.ReactElement {
|
|||||||
onClick: () => updateQuery("q", ""),
|
onClick: () => updateQuery("q", ""),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
errorNode={errorNode}
|
<PracticeTable items={filteredItems} />
|
||||||
pagination={
|
)}
|
||||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
</div>
|
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||||
}
|
</div>
|
||||||
>
|
</>
|
||||||
<PracticeTable items={filteredItems} />
|
);
|
||||||
</ListPageShell>
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自适应练习面板:发起练习 + 练习历史(迁移自 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,
|
questionTypeToBadgeClass,
|
||||||
truncateContent,
|
truncateContent,
|
||||||
} from "@/features/teacher/questions/transformations";
|
} from "@/features/teacher/questions/transformations";
|
||||||
|
import { ImportExportButtons } from "@/features/teacher/questions/components/import-export-buttons";
|
||||||
|
import { QuestionActions } from "@/features/teacher/questions/components/question-actions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -110,12 +112,22 @@ export function QuestionsListClient(): React.ReactElement {
|
|||||||
description={t("list.description")}
|
description={t("list.description")}
|
||||||
icon={<HelpCircle className="size-6" />}
|
icon={<HelpCircle className="size-6" />}
|
||||||
actions={
|
actions={
|
||||||
<Button asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/shell/teacher/questions">
|
<ImportExportButtons
|
||||||
<Plus className="mr-1 size-4" />
|
filter={{
|
||||||
{t("list.new")}
|
type: typeFilter || undefined,
|
||||||
</Link>
|
difficulty: difficultyFilter || undefined,
|
||||||
</Button>
|
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={
|
filters={
|
||||||
<>
|
<>
|
||||||
@@ -248,12 +260,7 @@ function QuestionsTable({
|
|||||||
{formatQuestionDate(q.createdAt)}
|
{formatQuestionDate(q.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<Link
|
<QuestionActions question={q} />
|
||||||
href={`/shell/teacher/questions?id=${q.id}`}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{t("list.viewDetail")}
|
|
||||||
</Link>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
*
|
*
|
||||||
* 数据契约:
|
* 数据契约:
|
||||||
* - 列表查询 scheduleChanges(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
* - 列表查询 scheduleChanges(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||||
|
* - 创建 mutation createScheduleChange(input) ❌ schema 无 Mutation 类型 → MSW 兜底
|
||||||
|
* - 审批/驳回 mutation ❌ 无对应 hook → @contract-pending,本地 stub 暂不写入
|
||||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#schedule-changes-list
|
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#schedule-changes-list
|
||||||
*
|
*
|
||||||
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
||||||
@@ -13,27 +15,38 @@
|
|||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 B2 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { CalendarClock } from "lucide-react";
|
import { CalendarClock, Plus } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useScheduleChanges, type ScheduleChangeListItem } from "@/lib/api";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
useClasses,
|
||||||
|
useCreateScheduleChange,
|
||||||
|
useScheduleChanges,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type { ScheduleChangeListItem } from "@/lib/api";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
|
|
||||||
|
import { ScheduleChangeForm } from "./components/schedule-change-form";
|
||||||
import {
|
import {
|
||||||
formatScheduleChangeDate,
|
ScheduleChangeList,
|
||||||
formatScheduleChangeDay,
|
type ScheduleChangeReviewSubmitInput,
|
||||||
formatScheduleChangeStatus,
|
} from "./components/schedule-change-list";
|
||||||
formatScheduleChangeType,
|
|
||||||
getScheduleChangeSummary,
|
|
||||||
scheduleChangeStatusToBadgeClass,
|
|
||||||
scheduleChangeTypeToBadgeClass,
|
|
||||||
} from "@/features/teacher/schedule-changes/transformations";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -45,18 +58,42 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [reviewing, setReviewing] = useState(false);
|
||||||
|
|
||||||
const classId = searchParams.get("classId") ?? "";
|
const classId = searchParams.get("classId") ?? "";
|
||||||
const statusFilter = searchParams.get("status") ?? "";
|
const statusFilter = searchParams.get("status") ?? "";
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
// @contract-pending:MSW 兜底
|
// @contract-pending:MSW 兜底
|
||||||
const { data, loading, error } = useScheduleChanges({
|
const { data, loading, error, refetch } = useScheduleChanges({
|
||||||
classId: classId || undefined,
|
classId: classId || undefined,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @contract-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)—— 后端补齐列表查询后改服务端筛选
|
// 客户端二次筛选(q)—— 后端补齐列表查询后改服务端筛选
|
||||||
const filteredItems = useMemo<ScheduleChangeListItem[]>(() => {
|
const filteredItems = useMemo<ScheduleChangeListItem[]>(() => {
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
@@ -80,6 +117,43 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (input: {
|
||||||
|
classId: string;
|
||||||
|
changeType: "RESCHEDULE" | "CANCEL" | "SUBSTITUTE" | "MERGE";
|
||||||
|
originalLesson: string;
|
||||||
|
originalDate: string;
|
||||||
|
reason: string;
|
||||||
|
newDate?: string;
|
||||||
|
newPeriod?: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
await createScheduleChange({
|
||||||
|
classId: input.classId,
|
||||||
|
originalLesson: input.originalLesson,
|
||||||
|
originalDate: input.originalDate,
|
||||||
|
changeType: input.changeType,
|
||||||
|
newDate: input.newDate,
|
||||||
|
newPeriod: input.newPeriod,
|
||||||
|
reason: input.reason,
|
||||||
|
});
|
||||||
|
notify.success(t("form.createSuccess"));
|
||||||
|
setCreateOpen(false);
|
||||||
|
refetch?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// @contract-pending:审批/驳回 mutation 尚未补齐,本地 stub 不写入
|
||||||
|
const handleSubmitReview = async (
|
||||||
|
_input: ScheduleChangeReviewSubmitInput,
|
||||||
|
): Promise<void> => {
|
||||||
|
setReviewing(true);
|
||||||
|
try {
|
||||||
|
// TODO: 后端补齐 approve/reject mutation 后切换为真实调用
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
notify.success(t("review.stubSuccess"));
|
||||||
|
} finally {
|
||||||
|
setReviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const errorNode = error ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -96,6 +170,12 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
|||||||
title={t("list.title")}
|
title={t("list.title")}
|
||||||
description={t("list.description")}
|
description={t("list.description")}
|
||||||
icon={<CalendarClock className="size-6" />}
|
icon={<CalendarClock className="size-6" />}
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("list.createAction")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
filters={
|
filters={
|
||||||
<>
|
<>
|
||||||
<FilterSearchInput
|
<FilterSearchInput
|
||||||
@@ -145,119 +225,29 @@ export function ScheduleChangesListClient(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ScheduleChangeTable items={filteredItems} />
|
<ScheduleChangeList
|
||||||
|
items={filteredItems}
|
||||||
|
canApprove
|
||||||
|
onSubmitReview={handleSubmitReview}
|
||||||
|
onReviewed={() => refetch?.()}
|
||||||
|
submitting={reviewing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[640px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("form.dialogTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("form.dialogDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScheduleChangeForm
|
||||||
|
classes={classOptions}
|
||||||
|
teachers={teacherOptions}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
submitting={creating}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 调课列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
|
||||||
*/
|
|
||||||
function ScheduleChangeTable({
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
items: ScheduleChangeListItem[];
|
|
||||||
}): React.ReactElement {
|
|
||||||
const t = useTranslations("scheduleChanges");
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b bg-muted/30">
|
|
||||||
<tr>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colSummary")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colClassName")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colOriginalDate")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colNewDate")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colReason")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colApplicant")}
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
{t("list.colCreatedAt")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{items.map((change) => (
|
|
||||||
<tr key={change.id} className="hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">
|
|
||||||
{getScheduleChangeSummary(change)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-xs">{change.className}</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<ScheduleChangeTypeBadge type={change.changeType} />
|
|
||||||
</td>
|
|
||||||
<td className="p-3 font-mono text-xs">
|
|
||||||
{formatScheduleChangeDay(change.originalDate)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 font-mono text-xs">
|
|
||||||
{formatScheduleChangeDay(change.newDate)}
|
|
||||||
{change.newPeriod ? ` ${change.newPeriod}` : ""}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-xs">
|
|
||||||
<p className="max-w-xs truncate" title={change.reason}>
|
|
||||||
{change.reason}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<ScheduleChangeStatusBadge status={change.status} />
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-xs">{change.applicantName}</td>
|
|
||||||
<td className="p-3 font-mono text-xs">
|
|
||||||
{formatScheduleChangeDate(change.createdAt)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调课状态徽章。
|
|
||||||
*/
|
|
||||||
function ScheduleChangeStatusBadge({
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
status: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const label = formatScheduleChangeStatus(status);
|
|
||||||
const cls = scheduleChangeStatusToBadgeClass(status);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调课类型徽章。
|
|
||||||
*/
|
|
||||||
function ScheduleChangeTypeBadge({
|
|
||||||
type,
|
|
||||||
}: {
|
|
||||||
type: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const label = formatScheduleChangeType(type);
|
|
||||||
const cls = scheduleChangeTypeToBadgeClass(type);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
isTextbookEditable,
|
isTextbookEditable,
|
||||||
sortChaptersByOrder,
|
sortChaptersByOrder,
|
||||||
} from "@/features/teacher/textbooks/transformations";
|
} from "@/features/teacher/textbooks/transformations";
|
||||||
|
import { KnowledgeGraph } from "./components/knowledge-graph";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||||
@@ -111,10 +112,33 @@ export function TextbookDetailClient(): React.ReactElement {
|
|||||||
mswNotice={t("detail.chaptersMswNotice")}
|
mswNotice={t("detail.chaptersMswNotice")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{data ? <KnowledgeGraphSection textbookId={data.id} /> : null}
|
||||||
</DetailPageShell>
|
</DetailPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识图谱区(详情页内嵌,使用迁移后的 KnowledgeGraph 组件)。
|
||||||
|
*
|
||||||
|
* 容器使用 h-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 详情页模板)。
|
* 详情基本信息区(对齐 §7.3 详情页模板)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2327,31 +2327,21 @@ export function useUpdateViewport(): {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
export function useAdminStudents(
|
export function useAdminStudents(
|
||||||
filter: StudentFilter,
|
filter: StudentFilter,
|
||||||
pagination: Pagination,
|
|
||||||
): UseQueryResult<PaginatedResult<AdminStudent>> {
|
): UseQueryResult<PaginatedResult<AdminStudent>> {
|
||||||
const result = useWidgetQuery<
|
const result = useWidgetQuery<
|
||||||
{ adminStudents: PaginatedResult<AdminStudent> },
|
{ adminStudents: PaginatedResult<AdminStudent> },
|
||||||
{ filter: StudentFilter; limit: number; offset: number }
|
{ filter: StudentFilter }
|
||||||
>(GET_ADMIN_STUDENTS_DOC, {
|
>(GET_ADMIN_STUDENTS_DOC, { filter });
|
||||||
filter,
|
|
||||||
limit: pagination.limit,
|
|
||||||
offset: pagination.offset,
|
|
||||||
});
|
|
||||||
return { ...result, data: result.data?.adminStudents };
|
return { ...result, data: result.data?.adminStudents };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAdminTeachers(
|
export function useAdminTeachers(
|
||||||
filter: TeacherFilter,
|
filter: TeacherFilter,
|
||||||
pagination: Pagination,
|
|
||||||
): UseQueryResult<PaginatedResult<AdminTeacher>> {
|
): UseQueryResult<PaginatedResult<AdminTeacher>> {
|
||||||
const result = useWidgetQuery<
|
const result = useWidgetQuery<
|
||||||
{ adminTeachers: PaginatedResult<AdminTeacher> },
|
{ adminTeachers: PaginatedResult<AdminTeacher> },
|
||||||
{ filter: TeacherFilter; limit: number; offset: number }
|
{ filter: TeacherFilter }
|
||||||
>(GET_ADMIN_TEACHERS_DOC, {
|
>(GET_ADMIN_TEACHERS_DOC, { filter });
|
||||||
filter,
|
|
||||||
limit: pagination.limit,
|
|
||||||
offset: pagination.offset,
|
|
||||||
});
|
|
||||||
return { ...result, data: result.data?.adminTeachers };
|
return { ...result, data: result.data?.adminTeachers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ import { ApiError } from "./errors";
|
|||||||
import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
|
import type { PaginatedResult, Pagination, UseQueryResult } from "./types";
|
||||||
import {
|
import {
|
||||||
GET_USERS_DOC,
|
GET_USERS_DOC,
|
||||||
|
GET_USER_DOC,
|
||||||
UPDATE_USER_STATUS_DOC,
|
UPDATE_USER_STATUS_DOC,
|
||||||
UPDATE_USER_ROLE_DOC,
|
UPDATE_USER_ROLE_DOC,
|
||||||
|
UPDATE_USER_DOC,
|
||||||
|
DELETE_USER_DOC,
|
||||||
|
ASSIGN_USER_ROLES_DOC,
|
||||||
GET_ROLES_DOC,
|
GET_ROLES_DOC,
|
||||||
GET_PERMISSIONS_DOC,
|
GET_PERMISSIONS_DOC,
|
||||||
UPDATE_ROLE_PERMISSIONS_DOC,
|
UPDATE_ROLE_PERMISSIONS_DOC,
|
||||||
@@ -28,6 +32,8 @@ import {
|
|||||||
GET_INVITATION_CODES_DOC,
|
GET_INVITATION_CODES_DOC,
|
||||||
CREATE_INVITATION_CODE_DOC,
|
CREATE_INVITATION_CODE_DOC,
|
||||||
REVOKE_INVITATION_CODE_DOC,
|
REVOKE_INVITATION_CODE_DOC,
|
||||||
|
GENERATE_INVITATION_CODES_DOC,
|
||||||
|
DELETE_INVITATION_CODES_DOC,
|
||||||
GET_SCHOOL_DOC,
|
GET_SCHOOL_DOC,
|
||||||
UPDATE_SCHOOL_DOC,
|
UPDATE_SCHOOL_DOC,
|
||||||
GET_PLUGIN_REGISTRY_DOC,
|
GET_PLUGIN_REGISTRY_DOC,
|
||||||
@@ -50,14 +56,48 @@ export interface User {
|
|||||||
role: string;
|
role: string;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/**
|
||||||
|
* 手机号(@contract-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 = {
|
export type UserQueryVars = {
|
||||||
role: string | null;
|
role: string | null;
|
||||||
limit: number;
|
/** @contract-pending 真实分页待 IAM 契约就绪,MSW 兜底时忽略 */
|
||||||
offset: number;
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户编辑输入(@contract-pending:schema 未就绪,MSW 兜底)。
|
||||||
|
* 所有字段可选,仅传入需变更的字段。
|
||||||
|
*/
|
||||||
|
export interface UserUpdateInput {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Types: RBAC
|
// Types: RBAC
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -67,6 +107,11 @@ export interface Permission {
|
|||||||
resource: string;
|
resource: string;
|
||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* 权限值(如 `user:create`,@contract-pending:schema 未就绪,MSW 兜底)。
|
||||||
|
* 缺省视为未知,UI 用 `-` 占位。
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RolePermission {
|
export interface RolePermission {
|
||||||
@@ -90,6 +135,29 @@ export interface Role {
|
|||||||
* 列表 MSW 兜底可能缺省,UI 需容忍 undefined。
|
* 列表 MSW 兜底可能缺省,UI 需容忍 undefined。
|
||||||
*/
|
*/
|
||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否启用(列表查询可选返回)。
|
||||||
|
* 缺省视为 true(兼容旧 MSW 数据)。
|
||||||
|
* @contract-pending schema 无此字段,MSW 兜底
|
||||||
|
*/
|
||||||
|
isEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 关联用户数(列表查询可选返回)。
|
||||||
|
* 缺省视为 0。
|
||||||
|
* @contract-pending schema 无此字段,MSW 兜底
|
||||||
|
*/
|
||||||
|
userCount?: number;
|
||||||
|
/**
|
||||||
|
* 更新时间 ISO 字符串(列表查询可选返回)。
|
||||||
|
* 缺省视为未知,UI 用 "-" 占位。
|
||||||
|
* @contract-pending schema 无此字段,MSW 兜底
|
||||||
|
*/
|
||||||
|
updatedAt?: string;
|
||||||
|
/**
|
||||||
|
* 角色值(如 `role:teacher`,@contract-pending:schema 未就绪,MSW 兜底)。
|
||||||
|
* 缺省视为未知,UI 用 `-` 占位。
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -111,6 +179,11 @@ export interface AuditLog {
|
|||||||
* 列表 StatusBadge 显示 "--" 占位;契约补齐后切换为真实值。
|
* 列表 StatusBadge 显示 "--" 占位;契约补齐后切换为真实值。
|
||||||
*/
|
*/
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
/**
|
||||||
|
* 错误信息(status=failure/error 时有值)。
|
||||||
|
* @contract-pending schema 未提供此字段,MSW 兜底;详情对话框展示用。
|
||||||
|
*/
|
||||||
|
errorMessage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogFilter {
|
export interface AuditLogFilter {
|
||||||
@@ -126,9 +199,25 @@ export interface InvitationCode {
|
|||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
/** 角色展示名(@contract-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;
|
status: string;
|
||||||
usedCount: number;
|
usedCount: number;
|
||||||
maxUses: 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;
|
expiresAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
@@ -145,6 +234,51 @@ export type CreatedInvitationCode = Pick<
|
|||||||
"id" | "code" | "role" | "maxUses" | "expiresAt"
|
"id" | "code" | "role" | "maxUses" | "expiresAt"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量生成邀请码输入(对齐 CICD GenerateInvitationCodesInput)。
|
||||||
|
*
|
||||||
|
* 字段说明:
|
||||||
|
* - batchName:批次名称(可选,用于标识本次生成用途)
|
||||||
|
* - count:生成数量(1-1000)
|
||||||
|
* - role:角色代码(teacher/student/parent/admin)
|
||||||
|
* - expireDays:过期天数(与 expiresAt 二选一,expireDays 优先)
|
||||||
|
* - 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
|
// Types: School
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -169,6 +303,8 @@ export interface SchoolInput {
|
|||||||
currentTerm?: string;
|
currentTerm?: string;
|
||||||
semesterStart?: string;
|
semesterStart?: string;
|
||||||
semesterEnd?: 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(): {
|
export function useUpdateUserStatus(): {
|
||||||
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
|
run: (id: string, status: string) => Promise<{ id: string; status: string }>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -308,6 +456,101 @@ export function useUpdateUserRole(): {
|
|||||||
return { run, loading, error };
|
return { run, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(@contract-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
|
// Hooks: RBAC
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -450,6 +693,69 @@ export function useRevokeInvitationCode(): {
|
|||||||
return { run, loading, error };
|
return { run, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGenerateInvitationCodes(): {
|
||||||
|
run: (
|
||||||
|
input: GenerateInvitationCodesInput,
|
||||||
|
) => Promise<GenerateInvitationCodesResult>;
|
||||||
|
loading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
run: rawRun,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useWidgetMutation<
|
||||||
|
{ generateInvitationCodes: GenerateInvitationCodesResult },
|
||||||
|
{ input: GenerateInvitationCodesInput }
|
||||||
|
>(GENERATE_INVITATION_CODES_DOC);
|
||||||
|
|
||||||
|
const run = async (
|
||||||
|
input: GenerateInvitationCodesInput,
|
||||||
|
): Promise<GenerateInvitationCodesResult> => {
|
||||||
|
const data = await rawRun({ input });
|
||||||
|
if (!data?.generateInvitationCodes) {
|
||||||
|
throw new ApiError(
|
||||||
|
"Failed to generate invitation codes",
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data.generateInvitationCodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除邀请码(@contract-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
|
// Hooks: School settings
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
GET_ATTENDANCE_REPORT_DOC,
|
GET_ATTENDANCE_REPORT_DOC,
|
||||||
GET_ATTENDANCE_SHEET_DOC,
|
GET_ATTENDANCE_SHEET_DOC,
|
||||||
GET_ATTENDANCE_STATS_DOC,
|
GET_ATTENDANCE_STATS_DOC,
|
||||||
|
GET_CLASS_COMPARISON_DOC,
|
||||||
SAVE_ATTENDANCE_SHEET_DOC,
|
SAVE_ATTENDANCE_SHEET_DOC,
|
||||||
} from "./operations/attendance.graphql";
|
} from "./operations/attendance.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
@@ -382,3 +383,38 @@ export function useSaveAttendanceSheet(): {
|
|||||||
|
|
||||||
return { run, loading, error };
|
return { run, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 班级对比(@contract-pending)=====
|
||||||
|
|
||||||
|
/** 班级对比项(与 GET_CLASS_COMPARISON_DOC 返回结构一致) */
|
||||||
|
export interface ClassComparisonItem {
|
||||||
|
className: string;
|
||||||
|
attendanceRate: number;
|
||||||
|
totalStudents: number;
|
||||||
|
presentStudents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassComparisonResponse {
|
||||||
|
classComparison: ClassComparisonItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询班级出勤率对比(@contract-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 type { FetchPolicy } from "@apollo/client";
|
||||||
|
|
||||||
import { useWidgetQuery } from "../useWidgetQuery";
|
import { useWidgetQuery } from "../useWidgetQuery";
|
||||||
|
import { useWidgetMutation } from "../useWidgetMutation";
|
||||||
|
import { ApiError } from "./errors";
|
||||||
import {
|
import {
|
||||||
|
CREATE_CLASS_DOC,
|
||||||
|
CREATE_CLASS_SCHEDULE_DOC,
|
||||||
|
DELETE_CLASS_SCHEDULE_DOC,
|
||||||
|
GENERATE_CLASS_INVITATION_CODE_DOC,
|
||||||
GET_CLASS_INFO_DOC,
|
GET_CLASS_INFO_DOC,
|
||||||
|
GET_CLASS_INVITATION_CODES_DOC,
|
||||||
GET_CLASS_SCHEDULE_DOC,
|
GET_CLASS_SCHEDULE_DOC,
|
||||||
GET_CLASS_STUDENTS_DOC,
|
GET_CLASS_STUDENTS_DOC,
|
||||||
GET_CLASS_TEACHERS_DOC,
|
GET_CLASS_TEACHERS_DOC,
|
||||||
GET_CLASSES_DOC,
|
GET_CLASSES_DOC,
|
||||||
|
REVOKE_CLASS_INVITATION_CODE_DOC,
|
||||||
|
UPDATE_CLASS_DOC,
|
||||||
|
DELETE_CLASS_DOC,
|
||||||
|
UPDATE_CLASS_SCHEDULE_DOC,
|
||||||
} from "./operations/classes.graphql";
|
} from "./operations/classes.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
|
|
||||||
@@ -318,3 +329,374 @@ export function useClassTeachers(
|
|||||||
refetch: result.refetch,
|
refetch: result.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 课表 CRUD 输入类型(@contract-pending)=====
|
||||||
|
|
||||||
|
/** 创建/更新课表条目输入 */
|
||||||
|
export interface ClassScheduleInput {
|
||||||
|
classId: string;
|
||||||
|
weekday: number;
|
||||||
|
period: number;
|
||||||
|
subjectName: string;
|
||||||
|
teacherName: string;
|
||||||
|
classroom: string | null;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 班级邀请码类型(@contract-pending)=====
|
||||||
|
|
||||||
|
/** 班级邀请码 */
|
||||||
|
export interface ClassInvitationCode {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
usedCount: number;
|
||||||
|
maxUses: number | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成班级邀请码输入 */
|
||||||
|
export interface GenerateClassInvitationCodeInput {
|
||||||
|
classId: string;
|
||||||
|
expiresInHours: number | null;
|
||||||
|
maxUses: number | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 响应类型(@contract-pending)=====
|
||||||
|
|
||||||
|
interface ClassInvitationCodesResponse {
|
||||||
|
classInvitationCodes: ClassInvitationCode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateClassScheduleResponse {
|
||||||
|
createClassSchedule: ClassScheduleItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateClassScheduleResponse {
|
||||||
|
updateClassSchedule: ClassScheduleItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Hooks: 课表 CRUD(@contract-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 {
|
import {
|
||||||
CREATE_COURSE_PLAN_DOC,
|
CREATE_COURSE_PLAN_DOC,
|
||||||
GET_COURSE_PLAN_DOC,
|
GET_COURSE_PLAN_DOC,
|
||||||
|
GET_COURSE_PLAN_TEMPLATES_DOC,
|
||||||
GET_COURSE_PLANS_DOC,
|
GET_COURSE_PLANS_DOC,
|
||||||
UPDATE_COURSE_PLAN_DOC,
|
UPDATE_COURSE_PLAN_DOC,
|
||||||
} from "./operations/course-plans.graphql";
|
} from "./operations/course-plans.graphql";
|
||||||
@@ -77,6 +78,20 @@ export interface CoursePlanListItem {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程计划模板(@contract-pending,MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 用于 create 页"从模板创建"对话框。模板是可复用的课程计划骨架,
|
||||||
|
* 字段最小化(不含 units / progress 等详情字段)。
|
||||||
|
*/
|
||||||
|
export interface CoursePlanTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
subject: string;
|
||||||
|
gradeLevel: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 列表查询响应(@contract-pending 假契约形状,MSW 返回此结构) */
|
/** 列表查询响应(@contract-pending 假契约形状,MSW 返回此结构) */
|
||||||
interface CoursePlansListResponse {
|
interface CoursePlansListResponse {
|
||||||
coursePlans: {
|
coursePlans: {
|
||||||
@@ -90,6 +105,11 @@ interface CoursePlanResponse {
|
|||||||
coursePlan: CoursePlanDetail | null;
|
coursePlan: CoursePlanDetail | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模板列表查询响应(@contract-pending,MSW 返回此结构) */
|
||||||
|
interface CoursePlanTemplatesResponse {
|
||||||
|
coursePlanTemplates: CoursePlanTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 创建课程计划输入 */
|
/** 创建课程计划输入 */
|
||||||
export interface CreateCoursePlanInput {
|
export interface CreateCoursePlanInput {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -130,6 +150,12 @@ export interface CoursePlansListFilter {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模板查询筛选(按学科 / 年级过滤,均可选) */
|
||||||
|
export interface CoursePlanTemplatesFilter {
|
||||||
|
subjectId?: string;
|
||||||
|
gradeLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 查询选项 =====
|
// ===== 查询选项 =====
|
||||||
|
|
||||||
export interface CoursePlanQueryOptions {
|
export interface CoursePlanQueryOptions {
|
||||||
@@ -214,6 +240,41 @@ export function useCoursePlan(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课程计划模板列表(@contract-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 兜底)。
|
* 创建课程计划 mutation(@contract-pending,MSW 兜底)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import type { FetchPolicy } from "@apollo/client";
|
|||||||
|
|
||||||
import { useWidgetQuery } from "../useWidgetQuery";
|
import { useWidgetQuery } from "../useWidgetQuery";
|
||||||
import { GET_ERROR_BOOK_STATS_DOC } from "./operations/dashboard.graphql";
|
import { GET_ERROR_BOOK_STATS_DOC } from "./operations/dashboard.graphql";
|
||||||
import { GET_ERROR_BOOK_ITEMS_DOC } from "./operations/error-book.graphql";
|
import {
|
||||||
|
GET_ERROR_BOOK_ITEMS_DOC,
|
||||||
|
GET_ERROR_BOOK_DETAIL_DOC,
|
||||||
|
} from "./operations/error-book.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
|
|
||||||
// ===== 数据类型(对齐 data-ana 子图,snake_case)=====
|
// ===== 数据类型(对齐 data-ana 子图,snake_case)=====
|
||||||
@@ -149,3 +152,309 @@ export function useTeacherErrorBookStats(
|
|||||||
refetch: result.refetch,
|
refetch: result.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 错题详情(@contract-pending MSW 兜底)=====
|
||||||
|
|
||||||
|
/** 错题详情(camelCase 对齐 admin 子图风格,用于详情对话框)*/
|
||||||
|
export interface ErrorBookItemDetail {
|
||||||
|
itemId: string;
|
||||||
|
content: string;
|
||||||
|
subjectId: string;
|
||||||
|
subjectName: string;
|
||||||
|
studentId: string;
|
||||||
|
studentName: string;
|
||||||
|
className: string;
|
||||||
|
errorCount: number;
|
||||||
|
lastErrorTime: string;
|
||||||
|
knowledgePointId: string;
|
||||||
|
knowledgePointTitle: string;
|
||||||
|
analysis: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** errorBookDetail 查询响应 */
|
||||||
|
interface ErrorBookDetailResponse {
|
||||||
|
errorBookDetail: ErrorBookItemDetail | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询错题详情(@contract-pending MSW 兜底)。
|
||||||
|
*
|
||||||
|
* 按 itemId 拉取错题完整详情,用于详情对话框。
|
||||||
|
* itemId 为空时跳过查询(enabled=false)。
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §5.3 契约纪律 / §9.1
|
||||||
|
*/
|
||||||
|
export function useErrorBookDetail(
|
||||||
|
itemId: string,
|
||||||
|
options?: ErrorBookQueryOptions,
|
||||||
|
): UseQueryResult<ErrorBookItemDetail | null> {
|
||||||
|
const result = useWidgetQuery<ErrorBookDetailResponse, { itemId: string }>(
|
||||||
|
GET_ERROR_BOOK_DETAIL_DOC,
|
||||||
|
{ itemId },
|
||||||
|
{
|
||||||
|
enabled: (options?.enabled ?? true) && itemId.length > 0,
|
||||||
|
fetchPolicy: options?.fetchPolicy,
|
||||||
|
pollInterval: options?.pollInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.data?.errorBookDetail ?? null,
|
||||||
|
loading: result.loading,
|
||||||
|
error: result.error,
|
||||||
|
refetch: result.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Mutation Hooks(@contract-pending MSW 兜底,迁移自 CICD actions.ts)=====
|
||||||
|
//
|
||||||
|
// portal-shell 使用 Apollo 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 { ApiError } from "./errors";
|
||||||
import {
|
import {
|
||||||
CREATE_EXAM_DOC,
|
CREATE_EXAM_DOC,
|
||||||
|
DELETE_EXAM_DOC,
|
||||||
|
DUPLICATE_EXAM_DOC,
|
||||||
GET_EXAM_ANALYTICS_DOC,
|
GET_EXAM_ANALYTICS_DOC,
|
||||||
GET_EXAM_BUILD_DOC,
|
GET_EXAM_BUILD_DOC,
|
||||||
GET_EXAM_DOC,
|
GET_EXAM_DOC,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
GET_QUESTIONS_LIBRARY_DOC,
|
GET_QUESTIONS_LIBRARY_DOC,
|
||||||
SAVE_EXAM_BUILD_DOC,
|
SAVE_EXAM_BUILD_DOC,
|
||||||
SAVE_EXAM_RICH_CONTENT_DOC,
|
SAVE_EXAM_RICH_CONTENT_DOC,
|
||||||
|
UPDATE_EXAM_STATUS_DOC,
|
||||||
} from "./operations/exams.graphql";
|
} from "./operations/exams.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
|
|
||||||
@@ -542,3 +545,118 @@ export function useSaveExamRichContent(): {
|
|||||||
|
|
||||||
return { run, loading, error };
|
return { run, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Delete / UpdateStatus / Duplicate Hooks(@contract-pending)=====
|
||||||
|
|
||||||
|
/** 删除考试响应 */
|
||||||
|
interface DeleteExamResponse {
|
||||||
|
deleteExam: { id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新考试状态响应 */
|
||||||
|
interface UpdateExamStatusResponse {
|
||||||
|
updateExamStatus: { id: string; status: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制考试响应 */
|
||||||
|
interface DuplicateExamResponse {
|
||||||
|
duplicateExam: { id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除考试(@contract-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 { useWidgetQuery } from "../useWidgetQuery";
|
||||||
import { ApiError } from "./errors";
|
import { ApiError } from "./errors";
|
||||||
import {
|
import {
|
||||||
|
ACQUIRE_DRAFT_LOCK_DOC,
|
||||||
|
BATCH_CREATE_GRADE_RECORDS_BY_EXAM_DOC,
|
||||||
|
BULK_DELETE_GRADES_DOC,
|
||||||
CREATE_GRADE_DOC,
|
CREATE_GRADE_DOC,
|
||||||
|
DELETE_GRADE_RECORD_DOC,
|
||||||
|
DOWNLOAD_GRADE_IMPORT_TEMPLATE_DOC,
|
||||||
|
GET_DRAFT_LOCK_DOC,
|
||||||
|
GET_EXAM_FOR_GRADE_ENTRY_DOC,
|
||||||
|
GET_EXAM_OPTIONS_FOR_ENTRY_DOC,
|
||||||
GET_GRADE_ANALYTICS_DOC,
|
GET_GRADE_ANALYTICS_DOC,
|
||||||
GET_GRADE_DOC,
|
GET_GRADE_DOC,
|
||||||
GET_GRADES_LIST_DOC,
|
GET_GRADES_LIST_DOC,
|
||||||
GET_GRADE_STATS_DOC,
|
GET_GRADE_STATS_DOC,
|
||||||
GET_REPORT_CARD_DOC,
|
GET_REPORT_CARD_DOC,
|
||||||
|
GET_SCHOOL_WIDE_GRADE_SUMMARY_DOC,
|
||||||
|
IMPORT_GRADES_FROM_EXCEL_DOC,
|
||||||
|
RELEASE_DRAFT_LOCK_DOC,
|
||||||
|
UNDO_GRADE_ENTRY_DOC,
|
||||||
|
UPDATE_GRADE_RECORD_DOC,
|
||||||
} from "./operations/grades.graphql";
|
} from "./operations/grades.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
|
|
||||||
@@ -424,3 +437,647 @@ export function useReportCard(
|
|||||||
refetch: result.refetch,
|
refetch: result.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 更新/删除/批量删除(@contract-pending 全 MSW)=====
|
||||||
|
|
||||||
|
/** 更新成绩输入 */
|
||||||
|
export interface UpdateGradeInput {
|
||||||
|
title?: string;
|
||||||
|
score?: number;
|
||||||
|
totalScore?: number;
|
||||||
|
type?: string;
|
||||||
|
semester?: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UpdateGrade mutation 响应 */
|
||||||
|
interface UpdateGradeResponse {
|
||||||
|
updateGrade: { gradeId: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DeleteGrade mutation 响应 */
|
||||||
|
interface DeleteGradeResponse {
|
||||||
|
deleteGrade: { gradeId: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BulkDeleteGrades mutation 响应 */
|
||||||
|
interface BulkDeleteGradesResponse {
|
||||||
|
bulkDeleteGrades: { count: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新成绩记录 mutation(@contract-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 { ApiError } from "./errors";
|
||||||
import {
|
import {
|
||||||
ASSIGN_HOMEWORK_DOC,
|
ASSIGN_HOMEWORK_DOC,
|
||||||
|
BATCH_AUTO_GRADE_DOC,
|
||||||
|
DELETE_SCAN_DOC,
|
||||||
GET_AI_BATCH_GRADING_DOC,
|
GET_AI_BATCH_GRADING_DOC,
|
||||||
GET_ASSIGNMENT_SUBMISSIONS_DOC,
|
GET_ASSIGNMENT_SUBMISSIONS_DOC,
|
||||||
GET_HOMEWORK_DOC,
|
GET_HOMEWORK_DOC,
|
||||||
GET_HOMEWORK_LIST_DOC,
|
GET_HOMEWORK_LIST_DOC,
|
||||||
GET_HOMEWORK_SUBMISSIONS_DOC,
|
GET_HOMEWORK_SUBMISSIONS_DOC,
|
||||||
|
GET_SCANS_DOC,
|
||||||
GET_SUBMISSION_DETAIL_DOC,
|
GET_SUBMISSION_DETAIL_DOC,
|
||||||
GRADE_SUBMISSION_DOC,
|
GRADE_SUBMISSION_DOC,
|
||||||
RECORD_GRADE_DOC,
|
RECORD_GRADE_DOC,
|
||||||
|
SAVE_HOMEWORK_ANSWER_DOC,
|
||||||
SAVE_SCAN_GRADING_DOC,
|
SAVE_SCAN_GRADING_DOC,
|
||||||
|
START_HOMEWORK_SUBMISSION_DOC,
|
||||||
|
SUBMIT_HOMEWORK_DOC,
|
||||||
} from "./operations/homework.graphql";
|
} from "./operations/homework.graphql";
|
||||||
import type { UseQueryResult } from "./types";
|
import type { UseQueryResult } from "./types";
|
||||||
|
|
||||||
@@ -610,3 +616,295 @@ export function useRecordGrade(): {
|
|||||||
|
|
||||||
return { run, loading, error };
|
return { run, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 学生作答相关 hooks(@contract-pending 全 MSW)=====
|
||||||
|
// 用于 /shell/teacher/homework/[id]/take 学生作答页
|
||||||
|
// 关联:ARCHITECTURE.md §5.4 / §9.1 学生作答页 / §11.4 契约工单
|
||||||
|
|
||||||
|
/** 扫描图(与 ScanImage 对齐,供组件直接消费) */
|
||||||
|
export interface HomeworkScan {
|
||||||
|
fileId: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
originalName?: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始作答输入 */
|
||||||
|
export interface StartHomeworkSubmissionInput {
|
||||||
|
assignmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始作答响应 */
|
||||||
|
interface StartHomeworkSubmissionResponse {
|
||||||
|
startHomeworkSubmission: { submissionId: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存单题答案输入 */
|
||||||
|
export interface SaveHomeworkAnswerInput {
|
||||||
|
submissionId: string;
|
||||||
|
questionId: string;
|
||||||
|
answerJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存单题答案响应 */
|
||||||
|
interface SaveHomeworkAnswerResponse {
|
||||||
|
saveHomeworkAnswer: { submissionId: string; questionId: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交作业输入 */
|
||||||
|
export interface SubmitHomeworkInput {
|
||||||
|
submissionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交作业响应 */
|
||||||
|
interface SubmitHomeworkResponse {
|
||||||
|
submitHomework: { submissionId: string; totalScore: number | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描图查询响应 */
|
||||||
|
interface GetScansResponse {
|
||||||
|
scans: HomeworkScan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除扫描图输入 */
|
||||||
|
export interface DeleteScanInput {
|
||||||
|
submissionId: string;
|
||||||
|
fileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除扫描图响应 */
|
||||||
|
interface DeleteScanResponse {
|
||||||
|
deleteScan: { fileId: string; success: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量自动批改输入 */
|
||||||
|
export interface BatchAutoGradeInput {
|
||||||
|
submissionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量自动批改响应 */
|
||||||
|
interface BatchAutoGradeResponse {
|
||||||
|
batchAutoGrade: {
|
||||||
|
processedCount: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始作答 mutation(@contract-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