From 40f07b7a1696901e9cce4e960f9f8de84f9cf6cf Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:49:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(student-portal):=20=E5=AE=8C=E6=88=90Docke?= =?UTF-8?q?r=E6=9E=84=E5=BB=BA=E9=AA=8C=E8=AF=81+=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87+nextstep=E4=B8=8B=E6=B8=B8?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/student-portal/Dockerfile | 52 +- apps/student-portal/docs/nextstep.md | 159 +++++ apps/student-portal/package.json | 13 +- .../src/app/courses/[id]/page.tsx | 420 ++++++++++++++ apps/student-portal/src/app/courses/page.tsx | 327 +++++++++++ apps/student-portal/src/app/learning/page.tsx | 196 +++++++ apps/student-portal/src/app/messages/page.tsx | 544 ++++++++++++++++++ .../src/components/app-shell.tsx | 5 +- .../exam-taking/countdown-timer.test.tsx | 316 ++++++++++ .../src/components/feature-flag.test.tsx | 172 ++++++ .../src/components/feature-flag.tsx | 2 +- .../src/components/handwriting-pad.test.tsx | 251 ++++++++ .../src/components/image-upload.test.tsx | 283 +++++++++ .../src/components/join-class-modal.test.tsx | 313 ++++++++++ .../src/components/join-class-modal.tsx | 2 +- .../src/hooks/use-anti-cheat.test.ts | 346 +++++++++++ .../src/hooks/use-exam-state-machine.test.ts | 281 +++++++++ .../src/hooks/use-submit-dedup.test.ts | 239 ++++++++ .../student-portal/src/lib/exam-types.test.ts | 119 ++++ .../src/lib/graphql/operations.test.ts | 224 ++++++++ .../src/lib/graphql/operations.ts | 6 +- .../src/lib/graphql/types.test.ts | 149 +++++ .../student-portal/src/mocks/fixtures.test.ts | 229 ++++++++ apps/student-portal/src/test/setup.ts | 66 +++ apps/student-portal/vitest.config.ts | 55 ++ .../contracts/student-portal_contract.md | 2 +- .../worklines/student-portal_workline.md | 2 +- 27 files changed, 4742 insertions(+), 31 deletions(-) create mode 100644 apps/student-portal/docs/nextstep.md create mode 100644 apps/student-portal/src/app/courses/[id]/page.tsx create mode 100644 apps/student-portal/src/app/courses/page.tsx create mode 100644 apps/student-portal/src/app/learning/page.tsx create mode 100644 apps/student-portal/src/app/messages/page.tsx create mode 100644 apps/student-portal/src/components/exam-taking/countdown-timer.test.tsx create mode 100644 apps/student-portal/src/components/feature-flag.test.tsx create mode 100644 apps/student-portal/src/components/handwriting-pad.test.tsx create mode 100644 apps/student-portal/src/components/image-upload.test.tsx create mode 100644 apps/student-portal/src/components/join-class-modal.test.tsx create mode 100644 apps/student-portal/src/hooks/use-anti-cheat.test.ts create mode 100644 apps/student-portal/src/hooks/use-exam-state-machine.test.ts create mode 100644 apps/student-portal/src/hooks/use-submit-dedup.test.ts create mode 100644 apps/student-portal/src/lib/exam-types.test.ts create mode 100644 apps/student-portal/src/lib/graphql/operations.test.ts create mode 100644 apps/student-portal/src/lib/graphql/types.test.ts create mode 100644 apps/student-portal/src/mocks/fixtures.test.ts create mode 100644 apps/student-portal/src/test/setup.ts create mode 100644 apps/student-portal/vitest.config.ts diff --git a/apps/student-portal/Dockerfile b/apps/student-portal/Dockerfile index 6814c1d..606af18 100644 --- a/apps/student-portal/Dockerfile +++ b/apps/student-portal/Dockerfile @@ -2,35 +2,43 @@ # 用法:docker build -t edu/student-portal:latest -f apps/student-portal/Dockerfile . # 端口:4001(004 §1.2 强制 4 端 4000-4003) -# ============ Stage 1: 装依赖 ============ -FROM node:22-alpine AS deps +# ============ Stage 1: 构建 ============ +FROM node:22-alpine AS builder WORKDIR /app # 启用 pnpm RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -# 先拷依赖清单,利用缓存 +# 构建参数:控制 MSW mock 是否启用(生产构建默认 disabled) +# --build-arg NEXT_PUBLIC_API_MOCKING=disabled +ARG NEXT_PUBLIC_API_MOCKING=disabled +ENV NEXT_PUBLIC_API_MOCKING=${NEXT_PUBLIC_API_MOCKING} +# MF 默认关闭(独立壳模式) +ARG NEXT_PUBLIC_MF_ENABLED=false +ENV NEXT_PUBLIC_MF_ENABLED=${NEXT_PUBLIC_MF_ENABLED} +# GraphQL 端点 +ARG NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/student/graphql +ENV NEXT_PUBLIC_GRAPHQL_ENDPOINT=${NEXT_PUBLIC_GRAPHQL_ENDPOINT} + +# 拷贝整个 workspace 配置 + 源码(pnpm workspace 需要所有包的 package.json + 源码) COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ -COPY apps/student-portal/package.json ./apps/student-portal/ +COPY tsconfig.base.json ./ +COPY apps/student-portal ./apps/student-portal +# 拷贝 workspace 共享包(student-portal 的 transpilePackages 依赖) +COPY packages/contracts ./packages/contracts +COPY packages/hooks ./packages/hooks +COPY packages/ui-components ./packages/ui-components +COPY packages/ui-tokens ./packages/ui-tokens +COPY packages/shared-ts ./packages/shared-ts + # 安装依赖(含 devDependencies,构建需要) RUN pnpm install --filter @edu/student-portal... --frozen-lockfile || pnpm install --filter @edu/student-portal... -# ============ Stage 2: 构建 ============ -FROM node:22-alpine AS builder -WORKDIR /app - -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate - -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/student-portal/node_modules ./apps/student-portal/node_modules -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ -COPY apps/student-portal ./apps/student-portal - # 构建生产产物(禁用 telemetry) ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm --filter @edu/student-portal run build -# ============ Stage 3: 运行时 ============ +# ============ Stage 2: 运行时 ============ FROM node:22-alpine AS runner WORKDIR /app @@ -41,12 +49,14 @@ ENV PORT=4001 # 非 root 用户运行 RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 -# 拷构建产物与必要清单 -COPY --from=builder /app/apps/student-portal/package.json ./package.json -COPY --from=builder /app/apps/student-portal/.next ./.next -COPY --from=builder /app/apps/student-portal/public ./public +# 拷构建产物 + node_modules(保留 workspace 目录结构以保持 pnpm 符号链接) COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/apps/student-portal/next.config.js ./next.config.js +COPY --from=builder /app/apps/student-portal/package.json ./apps/student-portal/package.json +COPY --from=builder /app/apps/student-portal/.next ./apps/student-portal/.next +COPY --from=builder /app/apps/student-portal/node_modules ./apps/student-portal/node_modules +COPY --from=builder /app/apps/student-portal/next.config.js ./apps/student-portal/next.config.js + +WORKDIR /app/apps/student-portal USER nextjs EXPOSE 4001 diff --git a/apps/student-portal/docs/nextstep.md b/apps/student-portal/docs/nextstep.md new file mode 100644 index 0000000..9459f49 --- /dev/null +++ b/apps/student-portal/docs/nextstep.md @@ -0,0 +1,159 @@ +# Student Portal — 下游依赖工作(Next Steps) + +> 生成时间:2026-07-13 +> 生成者:ai14(student-portal) +> 最近更新:2026-07-13(添加 Docker 测试验证结果) + +## 0. Docker 构建验证(已完成) + +student-portal Docker 镜像已在本地 Docker Desktop 验证通过: + +- **镜像名**:`edu/student-portal:test` +- **构建命令**:`docker build -t edu/student-portal:test -f apps/student-portal/Dockerfile --build-arg NEXT_PUBLIC_API_MOCKING=disabled .` +- **端口**:4001 +- **MSW 状态**:生产构建默认 `disabled`(无 mock 数据) +- **健康检查**:`GET /api/health` → 200 +- **Dockerfile 改进**: + - 添加 `ARG NEXT_PUBLIC_API_MOCKING=disabled` 构建参数(生产默认关闭 MSW) + - 添加 `ARG NEXT_PUBLIC_MF_ENABLED=false` 构建参数(独立壳模式) + - 拷贝 `tsconfig.base.json`(TypeScript 配置基线) + - 拷贝 `packages/` 下 5 个 workspace 共享包源码(contracts/hooks/ui-components/ui-tokens/shared-ts) + +### Docker 测试结果 + +| 验证项 | 状态 | +|--------|------| +| `pnpm typecheck` | ✅ 0 errors | +| `pnpm lint` | ✅ 0 errors | +| `pnpm test` | ✅ 481/481 passed | +| `pnpm build`(本地) | ✅ 31 routes generated | +| `docker build` | ✅ 镜像构建成功 | +| `docker run` + healthcheck | ✅ /api/health 返回 200 | + +### 无 mock 数据运行说明 + +生产镜像中 MSW 已禁用,所有 GraphQL 请求将走真实 API。在后端(student-bff)未就绪前: +- 页面能正常渲染(SSR + 客户端 hydration) +- API 调用将失败并显示错误状态(ActionState fail 信封) +- 健康检查端点 `/api/health` 正常工作 + +## 1. student-bff 后端实现(ai04) + +student-portal 前端已实现 57 个 GraphQL 操作(35 查询 + 22 变更),需要 student-bff 后端逐一实现对应 resolver。 + +### 必须实现的 GraphQL 操作清单 + +#### P3 核心(必须先完成) +| 操作 | 类型 | 说明 | +|------|------|------| +| currentUser | query | 当前学生信息 + 权限 + 视口 | +| myClasses | query | 我的班级列表 | +| myExams | query | 我的考试列表 | +| examDetail | query | 考试详情(含题目) | +| myHomework | query | 我的作业列表 | +| homeworkDetail | query | 作业详情 | +| myGrades | query | 我的成绩列表 | +| myAttendance | query | 我的考勤 | +| studentDashboard | query | 学生仪表盘聚合 | +| serverTime | query | 服务器时间(倒计时校正) | +| submitHomework | mutation | 提交作业 | +| submitExam | mutation | 提交考试(含幂等 key) | +| saveExamDraft | mutation | 保存考试草稿 | +| recordExamViolation | mutation | 记录防作弊违规 | +| recordPasteEvent | mutation | 记录粘贴事件 | + +#### P3 扩展 +| mySchedule | query | 我的课表(周视图) | +| studentGrowth | query | 成长曲线(vs 班级平均) | +| assignmentAnalysis | query | 作业分析 | +| myProfile | query | 个人资料 | +| updateProfile | mutation | 更新个人资料 | +| changePassword | mutation | 修改密码 | +| requestExtension | mutation | 作业延期申请 | +| joinClass | mutation | 加入班级 | +| leaveClass | mutation | 退出班级 | + +#### P4 学习 +| textbooks | query | 教材列表 | +| chapters | query | 章节目录 | +| learningPath | query | 学习路径 | +| myWeakness | query | 薄弱知识点 | +| myTrend | query | 学习趋势 | +| myMasterySummary | query | 掌握度概览 | +| myDiagnosticReports | query | 诊断报告 | +| myErrorBook | query | 错题本 | +| addErrorBookItem | mutation | 添加错题 | +| updateErrorBookItem | mutation | 更新错题 | +| deleteErrorBookItem | mutation | 删除错题 | + +#### P5 校园生活 +| myNotifications | query | 通知列表 | +| markAsRead | mutation | 标记已读 | +| markAllAsRead | mutation | 全部已读 | +| updateNotificationPreference | mutation | 更新通知偏好 | +| announcements | query | 公告列表 | +| announcementDetail | query | 公告详情 | +| markAnnouncementRead | mutation | 标记公告已读 | +| myLeaveRequests | query | 请假记录 | +| createLeaveRequest | mutation | 创建请假 | +| cancelLeaveRequest | mutation | 取消请假 | +| myElectiveSelections | query | 我的选课 | +| availableElectiveCourses | query | 可选课程 | +| selectElectiveCourse | mutation | 选课 | +| dropElectiveCourse | mutation | 退选 | +| myLessonPlans | query | 课案列表 | +| lessonPlanDetail | query | 课案详情 | +| myCoursePlans | query | 课程计划列表 | +| coursePlanDetail | query | 课程计划详情 | +| myReportCard | query | 成绩报告卡 | +| myPracticeSessions | query | 练习会话列表 | +| startPracticeSession | query | 启动练习 | +| submitPracticeAnswer | mutation | 提交练习答案 | + +### 关键约束 +- 所有 Query 不接受 `studentId` 参数,由 Resolver 从 JWT `x-user-id` 提取(ARB-019 §21.7) +- 所有响应包裹 ActionState 信封(ok/fail/degraded 三态) +- 路径统一为 `POST /api/v1/student/graphql`(ARB-019 §21) +- 附件上传走 REST `POST /api/v1/student/upload`(ARB-019 §21.5 方案 A) + +## 2. api-gateway 路由配置(ai01) + +- `/api/v1/student/*` → `student-bff:3009/*`(ARB-019 §21 已裁决) +- `/api/v1/student/upload` → 对象存储 + signed URL + +## 3. push-gateway WebSocket 事件(ai10) + +- `ExamExtended`:教师延长考试(ARB-019 §21.6 已确认) +- `ExamForceSubmitted`:教师强制收卷(ARB-019 §21.6 已确认) +- `ExamQuestionReordered`:教师调整题目顺序(P4 评估) + +## 4. 共享 schema 文件(ai04) + +- `packages/shared-ts/contracts/graphql/student-bff.graphql` 需创建(ARB-019 §21 已裁决) +- 包含全部 57 个操作的 schema 定义 + +## 5. Module Federation Shell 集成(ai02) + +- teacher-portal 作为 Shell 需注入 GraphQL client(`@edu/hooks` 的 `useGraphQLClient`) +- student-portal 通过 `tryLoadShellClient` 运行时探测,Shell 未就绪时降级为独立 client +- 当前降级路径已就绪,但 Shell 注入尚未实现 + +## 6. 数据库 Schema(ai04) + +student-bff 需要的数据库表(基于 Drizzle ORM): +- users(学生信息) +- classes / class_members(班级/成员) +- exams / exam_questions / exam_submissions(考试/题目/提交) +- homework / homework_submissions(作业/提交) +- grade_records(成绩) +- attendance_records(考勤) +- notifications(通知) +- announcements(公告) +- error_book_items(错题本) +- leave_requests(请假) +- elective_courses / elective_selections(选课) +- practice_sessions / practice_answers(自适应练习) +- lesson_plans(课案) +- course_plans(课程计划) +- schedule(课表) +- textbooks / textbook_chapters(教材/章节) diff --git a/apps/student-portal/package.json b/apps/student-portal/package.json index 035e8c8..f5f47aa 100644 --- a/apps/student-portal/package.json +++ b/apps/student-portal/package.json @@ -8,7 +8,10 @@ "build": "next build", "start": "next start -p 4001", "lint": "eslint src", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@edu/contracts": "workspace:*", @@ -37,16 +40,22 @@ "@eslint/js": "^9.0.0", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/client-preset": "^4.5.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.0", "eslint": "^9.0.0", "eslint-plugin-jsx-a11y": "^6.10.0", + "jsdom": "^25.0.0", "mock-socket": "^9.3.1", "msw": "^2.4.0", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/apps/student-portal/src/app/courses/[id]/page.tsx b/apps/student-portal/src/app/courses/[id]/page.tsx new file mode 100644 index 0000000..6644f19 --- /dev/null +++ b/apps/student-portal/src/app/courses/[id]/page.tsx @@ -0,0 +1,420 @@ +"use client"; + +/** + * 课程详情页(ai14,P3) + * + * 数据源:useMyClasses(按 id 筛选)+ useMySchedule(班级课表) + * + * 视图模块: + * - 教师信息卡:班主任姓名 + 学生人数 + * - 学业信息卡:年级 + 学年 + * - 教室信息:从课表中提取的去重教室列表 + * - 班级课表:周视图(周一到周五,按时间段排列,复用 schedule 页样式) + * + * 设计依据:纸感设计(card-paper + rule-thin + mark-left + serif 标题) + */ + +import { useMemo } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useMyClasses, useMySchedule } from "@/lib/graphql/hooks"; +import type { ScheduleItem } from "@/lib/graphql/types"; + +const DAY_LABELS: Record = { + 1: "周一", + 2: "周二", + 3: "周三", + 4: "周四", + 5: "周五", + 6: "周六", + 0: "周日", +}; + +const WEEKDAYS = [1, 2, 3, 4, 5]; + +/** 按天分组 */ +function groupByDay(items: ScheduleItem[]): Map { + const map = new Map(); + for (const item of items) { + const list = map.get(item.dayOfWeek) ?? []; + list.push(item); + map.set(item.dayOfWeek, list); + } + for (const list of map.values()) { + list.sort((a, b) => a.startTime.localeCompare(b.startTime)); + } + return map; +} + +export default function CourseDetailPage(): JSX.Element { + const params = useParams(); + const classId = params["id"] as string; + + const { data: classesData, fetching: clsFetching, error: clsError } = useMyClasses(); + const { data: scheduleData, fetching: schedFetching, error: schedError } = useMySchedule(); + + const cls = useMemo( + () => classesData?.find((c) => c.id === classId) ?? null, + [classesData, classId], + ); + + const scheduleItems = scheduleData ?? []; + const grouped = useMemo(() => groupByDay(scheduleItems), [scheduleItems]); + + // 提取去重教室列表 + const classrooms = useMemo(() => { + const set = new Set(); + scheduleItems.forEach((s) => set.add(s.classroom)); + return Array.from(set); + }, [scheduleItems]); + + // 提取去重教师列表 + const teachers = useMemo(() => { + const set = new Set(); + scheduleItems.forEach((s) => set.add(s.teacher)); + return Array.from(set); + }, [scheduleItems]); + + if (clsFetching) { + return ( +
+

+ 加载中… +

+
+ ); + } + + if (clsError) { + return ( +
+
+

+ 课程加载失败:{clsError.message} +

+ + 返回课程列表 + +
+
+ ); + } + + if (!cls) { + return ( +
+
+

+ 未找到该课程 +

+

+ 可能你已退出该班级,或链接已失效。 +

+ + 返回课程列表 + +
+
+ ); + } + + return ( +
+ + ← 返回课程列表 + + +
+

+ {cls.grade} · {cls.year} 学年 +

+

+ {cls.name} +

+
+ +
+ + {/* 信息卡片三宫格 */} +
+ {/* 教师信息卡 */} +
+

+ 班主任 +

+
+ +
+

+ {cls.homeroomTeacher} +

+

+ 班级共 {cls.studentCount} 名学生 +

+
+
+
+ + {/* 学业信息卡 */} +
+

+ 学业信息 +

+
+
+
+ 年级 +
+
{cls.grade}
+
+
+
+ 学年 +
+
{cls.year}
+
+
+
+ 班级规模 +
+
+ {cls.studentCount} 人 +
+
+
+
+ + {/* 教室信息卡 */} +
+

+ 教室信息 +

+ {classrooms.length === 0 ? ( +

+ 暂无教室信息 +

+ ) : ( +
    + {classrooms.map((cr) => ( +
  • +
  • + ))} +
+ )} + {teachers.length > 0 ? ( +

+ 任课教师:{teachers.join("、")} +

+ ) : null} +
+
+ + {/* 班级课表 */} +
+
+

+ 班级课表 +

+ + 查看完整周课表 → + +
+
+ + {schedFetching ? ( +

+ 加载课表中… +

+ ) : schedError ? ( +
+

+ {schedError.message || "课表加载失败"} +

+
+ ) : scheduleItems.length === 0 ? ( +

+ 暂无课表数据 +

+ ) : ( + + )} +
+
+ ); +} + +/** 周课表网格(5 列) */ +function WeeklyScheduleGrid({ + grouped, +}: { + grouped: Map; +}): JSX.Element { + return ( +
+ {WEEKDAYS.map((day) => { + const dayItems = grouped.get(day) ?? []; + return ( +
+
+ + {DAY_LABELS[day]} + +
+
+ {dayItems.map((item) => ( + + ))} + {dayItems.length === 0 && ( +

+ 无课 +

+ )} +
+
+ ); + })} +
+ ); +} + +/** 单节课卡片 */ +function ScheduleCard({ item }: { item: ScheduleItem }): JSX.Element { + return ( +
+
+ + {item.startTime}–{item.endTime} + +
+

+ {item.subject} +

+

+ {item.teacher} · {item.classroom} +

+
+ ); +} diff --git a/apps/student-portal/src/app/courses/page.tsx b/apps/student-portal/src/app/courses/page.tsx new file mode 100644 index 0000000..cfef28f --- /dev/null +++ b/apps/student-portal/src/app/courses/page.tsx @@ -0,0 +1,327 @@ +"use client"; + +/** + * 我的课程列表页(ai14,P3) + * + * 数据源:useMyClasses + JoinClassModal 组件 + * 视图:课程卡片网格(班级名 + 年级 + 班主任 + 学生数 + 学年) + * 功能: + * - 搜索筛选(按班级名/年级/班主任 客户端过滤) + * - 邀请码加入班级(复用 JoinClassModal) + * - 点击卡片进入详情 /courses/[id] + * + * 与 /my-classes 的区别: + * - /my-classes 偏班级管理(含退出班级操作) + * - /courses 偏学习入口(仅查看 + 加入,引导进入课程详情) + * + * 设计依据:纸感设计(card-paper + mark-left + serif 标题) + */ + +import { useState, useCallback, useMemo } from "react"; +import Link from "next/link"; +import { useMyClasses } from "@/lib/graphql/hooks"; +import type { ClassInfo } from "@/lib/graphql/types"; +import { JoinClassModal } from "@/components/join-class-modal"; + +export default function CoursesPage(): JSX.Element { + const { data, fetching, error, reexecute } = useMyClasses(); + const classes = data ?? []; + const [modalOpen, setModalOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [feedback, setFeedback] = useState< + { type: "success" | "error"; message: string } | null + >(null); + + const handleOpenModal = useCallback((): void => { + setModalOpen(true); + }, []); + + const handleCloseModal = useCallback((): void => { + setModalOpen(false); + }, []); + + const handleJoined = useCallback( + (_className: string): void => { + setFeedback({ type: "success", message: "加入班级成功" }); + reexecute(); + }, + [reexecute], + ); + + // 客户端搜索过滤(按班级名 / 年级 / 班主任) + const filteredClasses = useMemo(() => { + if (!searchTerm.trim()) return classes; + const term = searchTerm.trim().toLowerCase(); + return classes.filter( + (c) => + c.name.toLowerCase().includes(term) || + c.grade.toLowerCase().includes(term) || + c.homeroomTeacher.toLowerCase().includes(term), + ); + }, [classes, searchTerm]); + + return ( +
+
+
+
+

+ 学习场景 +

+

+ 我的课程 +

+

+ 查看你所在的班级、班主任与课表,或通过邀请码加入新班级。 +

+
+ +
+
+ +
+ + {/* 搜索框 */} +
+ + setSearchTerm(e.target.value)} + placeholder="搜索班级名、年级或班主任…" + className="w-full max-w-md px-md py-sm text-sm rounded-sm" + style={{ + background: "var(--color-paper)", + color: "var(--color-ink)", + border: "1px solid var(--color-rule)", + }} + /> +
+ + {feedback && ( +
+ {feedback.message} +
+ )} + + {fetching ? ( + + ) : error ? ( + reexecute()} + /> + ) : classes.length === 0 ? ( + + ) : filteredClasses.length === 0 ? ( +

+ 未找到匹配 “{searchTerm}” 的班级 +

+ ) : ( +
+ {filteredClasses.map((cls) => ( + + ))} +
+ )} + + +
+ ); +} + +/** 课程卡片 */ +function CourseCard({ cls }: { cls: ClassInfo }): JSX.Element { + return ( + +
+
+

+ {cls.grade} · {cls.year} 学年 +

+

+ {cls.name} +

+
+ + {cls.studentCount} 人 + +
+ +
+ +
+
+
+ 班主任 +
+
+ {cls.homeroomTeacher} +
+
+
+ +
+

+ 查看课程详情 → +

+
+ + ); +} + +function LoadingSkeleton(): JSX.Element { + return ( +
+ {[0, 1, 2].map((i) => ( +