66 Commits

Author SHA1 Message Date
SpecialX
2f8f3f3855 feat(portal-shell): grades 模块 5 页迁移(教师域 §9.1 B2)
§9.1 B2 grades 行:/grades、/entry、/analytics、/stats、/report-card
契约:grade(id)  真实查询;列表/分析/统计/成绩单 → MSW 兜底

新增文件:
- src/lib/api/grades.ts (6 hooks)
- src/lib/api/operations/grades.graphql.ts (6 documents)
- src/features/teacher/grades/ (7 files: transformations + tests + 5 clients)
- src/app/shell/teacher/grades/ (5 page.tsx + loading.tsx + error.tsx)

修改文件:
- src/mocks/graphql-data.ts (5 mock 常量 + 6 handler cases)
- src/messages/{zh-CN,en}.json (grades i18n)
- src/lib/api/{index,operations/index}.ts (导出 grades)
- scripts/check-page-count.ts (baseline 26 → 31)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 357 tests passed
- lint:tokens 0 errors
- check:pages 31 PASS

关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
2026-07-22 18:41:55 +08:00
SpecialX
081cb5fbc3 feat(portal-shell): homework 模块 7 页迁移(教师域 §9.1 B2)
§9.1 教师域 homework 模块完整迁移(继 exams 之后第二个 B2 模块):

7 页路由结构(与旧 teacher-portal 同构):
- /shell/teacher/homework:列表页(?classId/status/q 筛选)
- /shell/teacher/homework/new:布置作业表单页
- /shell/teacher/homework/[id]:详情 + 内联批改(含提交列表 + recordGrade 表单)
- /shell/teacher/homework/submissions:跨作业提交评审列表
- /shell/teacher/homework/submissions/[submissionId]:单份提交批改 + AI 建议 + 上下份导航
- /shell/teacher/homework/submissions/[submissionId]/scan-grading:扫描批改工作台(三栏)
- /shell/teacher/homework/assignments/[id]/submissions:按作业批量批改 + 统计 + AI 批量评分

数据契约(混合):
-  homework(id: ID!) 真实查询(schema 已就绪,详情页用)
-  列表/mutation/submissions/grading/aiBatchGrading 全部 @contract-pending MSW 兜底
  · 9 个 hook 走 MSW,待后端补齐 mutation 后切换真实 fetcher

§11.3 DoD 11 项验收:
1. route-permissions:EXACT + PREFIX 表 /shell/teacher/homework 已配置
2. 页面模板:list/new 用 ListPageShell/FormPageShell;detail/grading 用 DetailPageShell;
   scan-grading 用 WorkbenchPageShell(三栏,未使用 emptyNode)
3. 三态:loading(Skeleton)/error(errorNode 或 errorSummary)/empty(emptyNode) 全实现
4. lib/api hooks:homework.ts 10 个 hooks(useHomework 真实 + 9 个 MSW)
5. @contract-pending MSW:graphql-data.ts 扩展 6 块 mock + 10 个 switch case
6. i18n:homework 节点扩展 8 个分区共 130+ keys(list/detail/new/submissions/grading/
   scan/assignment/error)中英对齐
7. lint:0 errors(4 warnings 在 __generated__)
8. lint:tokens:0 errors
9. notify:mutation 反馈走 @/shared/lib/notify(非 sonner 直引)
10. vitest:transformations 纯函数单测齐全,全量 323/323 通过(新增 ~50 测试)
11. typecheck:0 errors(noUncheckedIndexedAccess 安全访问)

附带修复:
- 修复 2 处遗留 broken link:
  · widgets/sidebar/quick-actions: /homework/new → /shell/teacher/homework/new
  · widgets/topbar/global-search: /homework → /shell/teacher/homework
- scripts/check-page-count.ts baseline 同步 13 → 26(与 exams 6 + homework 7 一致)

剩余模块:grades(5)+lesson-plans(6)+questions(1)+textbooks(2)+attendance(4)+classes(3)+
students(1)+course-plans(2)+elective(3)+error-book(1)+diagnostic(2)+analytics(2)+ai-*(3)+
knowledge-graph(1)+practice(1)+schedule-changes(1)+leave(1) 共 39 页。
2026-07-22 18:11:15 +08:00
SpecialX
dca25fc42f feat(portal-shell): exams 三子页面迁移(analytics/build/edit)
§9.1 教师域 exams 模块补完(继 d066da5 列表/详情/表单后):
- /shell/teacher/exams/[id]/analytics:详情页(图表)- 混合契约
  · 基础统计  assignmentAnalysis(data-ana 子图,schema 已就绪)
  · 扩展字段(排名/每题正确率) MSW 兜底(@contract-pending)
  · 含 Summary/Distribution/QuestionAccuracy/Rankings 四区
- /shell/teacher/exams/[id]/build:工作台页(组卷)@contract-pending
  · 三栏:题库候选 / 已选题目 / 预览
  · 支持搜索/类型/难度筛选,添加/移除/上移/下移/改分
- /shell/teacher/exams/[id]/edit:工作台页(富文本试卷)@contract-pending
  · contentEditable + 工具栏(B/I/U/H1-H3/列表)
  · 右栏试卷属性面板

§11.3 DoD 11 项验收:
1. route-permissions:PREFIX 表 /shell/teacher/exams/ 已覆盖
2. 页面模板:analytics 用 DetailPageShell;build/edit 用 WorkbenchPageShell
3. 三态:loading/error/empty 均实现(workbench 用 errorNode 合并 empty)
4. lib/api hooks:useExamAnalytics/useExamBuild/useQuestionsLibrary/
   useSaveExamBuild/useExamRichEditor/useSaveExamRichContent 6 个
5. @contract-pending MSW 模式:graphql-data.ts 扩展 6 个 case
6. i18n:analytics(19 keys)+build(28 keys)+edit(13 keys) 中英对齐
7. lint:0 errors(4 warnings 在 __generated__)
8. lint:tokens:0 errors
9. notify:success/error/warning 走 @/shared/lib/notify(非 sonner 直引)
10. vitest:transformations 新增 10 函数 22 测试,全量 273/273 通过
11. typecheck:0 errors(noUncheckedIndexedAccess 安全 swap 写法)

剩余:proctoring 标注"二期 WS"按 §9.1 暂缓。
2026-07-22 17:38:51 +08:00
SpecialX
d066da563f feat(portal-shell): 教师域考试管理页面迁移(P2)
按 ARCHITECTURE.md §9.1/§10 P2 要求,迁移教师域 exams 模块:
- 列表页 /shell/teacher/exams(ListPageShell + URL 状态 + 客户端二次筛选)
- 详情页 /shell/teacher/exams/[id](DetailPageShell + 真实 exam(id) 查询)
- 新建页 /shell/teacher/exams/new(FormPageShell + MSW 兜底)
- 纯函数 transformations.ts + 19 个 vitest 单测
- @contract-pending:exams(classId) 列表查询、createExam mutation 走 MSW
- 三态 UI(loading/error/empty)+ 路由级 loading.tsx/error.tsx
- i18n:zh-CN/en 双语补全,无硬编码中文
- MSW handlers 支持 variables 透传

§11.3 DoD 验收:
- lint: 0 errors(4 个 __generated__ 预存警告)
- typecheck: 0 errors
- test: 250/250 passed(含 19 个新增 transformations 测试)
- lint:tokens: 0 errors
2026-07-22 17:02:05 +08:00
SpecialX
843c3c0144 docs(docs): sync known-issues with P1-7/P1-8 learnings 2026-07-22 16:13:03 +08:00
SpecialX
92f24e2e91 docs(portal-shell): align P1-7 acceptance criteria with actual scope 2026-07-22 16:10:52 +08:00
SpecialX
7c511e74bd feat(portal-shell): add CI structural checks for routes, pages, codegen (P1-8)
ARCHITECTURE.md §10 P1-8: three structural checks wired into CI to
prevent regressions identified in the §1.3 audit.

Scripts (apps/portal-shell/scripts/):
- check-route-table.ts: scans src/app/shell/**/page.tsx, parses
  route-permissions.ts (EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES), fails
  if any actual /shell/* route is unregistered. Reports ghost entries
  (EXACT declarations without page.tsx) as informational.
- check-page-count.ts: asserts total page.tsx >= 13 and per-category
  minimums (dashboards/login/root/forbidden/catch-all/dev-templates).
- check-codegen.ts: runs pnpm run codegen, fails if any output with
  skipDocumentsValidation:false has operations referencing non-existent
  schema fields (currently enforces dashboard-types.ts output from P1-7).

npm scripts: check:routes / check:pages / check:codegen / check:all

CI: .github/workflows/ci.yml quality-ts job — new "Portal-shell
structural checks (P1-8)" step between typecheck and test.

Acceptance (ARCHITECTURE.md §10 P1-8 — "CI 对预埋违规报红"):
- Route violation: planted /shell/test-violation/page.tsx → check:routes
  exits 1 with "unregistered route" error; reverted → PASS
- Codegen violation: planted non_existent_field in GetTeacherDashboard →
  check:codegen exits 1 with "Cannot query field" error; reverted → PASS
- Page count: baseline=13, deleting any page.tsx triggers FAIL
- Clean state: all 3 checks PASS (10 routes, 28 EXACT, 24 ghost entries
  informational, 13 pages, codegen 3 outputs SUCCESS)

Refs: ARCHITECTURE.md §5.3, §10 P1-8, §11.6, §11.7 红线 #5
2026-07-22 15:57:58 +08:00
SpecialX
0beeff6329 feat(portal-shell): restore codegen typescript-operations for data-ana domain (P1-7)
ARCHITECTURE.md §10 P1-7: dashboard domain's 6 operations strictly
match the schema, so disable skipDocumentsValidation for that output
and restore per-operation type generation.

Changes:
- codegen.yml: add dashboard-types.ts output (typescript +
  typescript-operations plugins, skipDocumentsValidation: false);
  move documents config into each generates entry
- dashboard.ts: remove 14 handwritten interfaces and 6 internal query
  type aliases; derive types via NonNullable<GetXxxQuery['xxx']> so
  the public hook API shape stays unchanged
- admin/student/teacher page.tsx: add ?? "--" / ?? 0 null guards on
  StatCard value props to match schema nullable semantics (parent page
  already uses toFixed chain, no change needed)

Acceptance (ARCHITECTURE.md §10 P1-7):
- codegen 3 outputs all SUCCESS
- tsc 0 errors / eslint 0 errors / vitest 231 passed / next build ok
- 6 operations strictly match schema with 0 errors

Refs: ARCHITECTURE.md §5.3 data layer / §10 P1-7
2026-07-22 15:37:06 +08:00
SpecialX
a28a6bd6ea feat(portal-shell): clean widget design tokens and fix lint:tokens (P1-6)
516 mechanical replacements across 25 widget files:
- spacing xs/sm/md/lg/xl to numeric 1/2/3/4/6
- text-heading-3 to text-lg font-semibold
- bg-danger to bg-destructive
- border border dedup

Fix .eslintrc.tokens.js to use typescript-eslint parser (was importing
uninstalled @typescript-eslint/parser). lint:tokens now passes.
2026-07-22 15:07:38 +08:00
SpecialX
9358372657 feat(portal-shell): add MSW mock layer with production bundle exclusion (P1-5)
MSW v2.7.0 fallback layer covering dashboard/users/exams/grades domains.
NEXT_PUBLIC_MSW=1 enables browser Service Worker + SSR route handler mock
responses without backend. Production build excludes all mock data via
Turbopack resolveAlias redirecting @/mocks to empty stub.

Acceptance: build bundle (client+server) verified clean of mock strings;
typecheck/lint/vitest (231 tests) all pass.
2026-07-22 14:48:30 +08:00
SpecialX
da05c9107a feat(portal-shell): integrate next-intl + merge messages from teacher-portal (P1-4)
- Add next-intl v4.13.2 with cookie-based locale (no i18n routing)
- Create src/i18n/request.ts reading NEXT_LOCALE cookie
- Merge zh-CN/en messages from teacher-portal + add shell.dev.templates namespace
- Wrap next.config.js with withNextIntl plugin (Turbopack resolveAlias)
- Refactor RootLayout to async + NextIntlClientProvider + getLocale/getMessages
- Replace ThemeI18nProvider with ThemeProvider (theme-only, i18n removed)
- Remove locale/setLocale from PluginStore
- Rework locale-switcher to useLocale/useTranslations + router.refresh
- Update dev/templates page to use getTranslations (Server Component)
- Fix WorkbenchPageShell test (loading prop + center instead of children)

Verified: locale switch via NEXT_LOCALE cookie changes <html lang> and messages
zh-CN: 保存/取消/切换侧栏 | en: Save/Cancel/Toggle sidebar
typecheck 0 errors | lint 0 errors 2 warnings (generated) | vitest 231 passed
2026-07-22 13:40:44 +08:00
SpecialX
994441c2dc feat(portal-shell): add page templates quartet (list/detail/form/workbench) (P1-3)
P1-3 验收通过:4 个页面模板 + 4 个 dev 示例页 + 三态规范。

新增文件:
- src/shared/components/page-templates/
  - list-page.tsx:ListPageShell + ListPageSkeleton
  - detail-page.tsx:DetailPageShell + DetailSection + DetailField + DetailPageSkeleton
  - form-page.tsx:FormPageShell + FormPageSkeleton
  - workbench-page.tsx:WorkbenchPageShell + WorkbenchPanel + WorkbenchPageSkeleton
  - index.ts:barrel 导出
- src/app/shell/dev/templates/
  - page.tsx:索引页(4 个模板入口)
  - list/page.tsx:列表页示例(支持 ?state=loading|empty|success)
  - detail/page.tsx:详情页示例
  - form/page.tsx:表单页示例
  - workbench/page.tsx:工作台页示例
- src/shared/components/__tests__/page-templates.test.tsx:19 个单测

修改文件:
- src/shared/lib/route-permissions.ts:新增 PREFIX /shell/dev/(空 config = 仅校验登录)
- ARCHITECTURE.md:P1-3 状态回填  + 验收证据

路径命名修正:
- 原 ARCHITECTURE.md 写 /shell/_dev/templates/*,但 Next.js 将下划线开头的
  文件夹视为"私有文件夹"(不参与路由),实测被 [[...route]] catch-all 兜底接管。
- 改用 dev 命名后,显式路由优先匹配,catch-all 不再触发。

三态规范验证:
- GET /shell/dev/templates/list?state=loading → 200,含 animate-pulse 骨架
- GET /shell/dev/templates/list?state=empty → 200,含"暂无数据"空态
- GET /shell/dev/templates/list(默认 success)→ 200,含表格数据

质量校验:
- tsc --noEmit 通过
- eslint(新/改文件)通过
- vitest run 全量 21 test files / 231 tests 全部通过(212 原有 + 19 新增)

Refs: apps/portal-shell/ARCHITECTURE.md §7.3 页面四种类型与模板、
      §7.4 页面级数据获取模式、§11.3 每页硬性清单(DoD)三态规范
2026-07-22 13:02:11 +08:00
SpecialX
03e3ec4f60 fix(portal-shell): wrap sidebar in client component to respect RSC boundary
P1-1 regression introduced by layout.tsx RSC refactor: navigation.ts
exports `icon: LucideIcon` (function refs) which cannot cross the RSC
boundary from a Server Component to a Client Component.

Fix:
- Introduce ShellSidebar (Client Component) that owns the navigation
  filtering + icon refs entirely on the client side.
- layout.tsx (RSC) now only passes serializable strings (`role` and
  `permsBitmap`) to ShellSidebar; no function references cross the
  boundary.

Error before fix:
  Error: Functions cannot be passed directly to Client Components
  unless you explicitly expose it by marking it with "use server".
  {$$typeof: ..., render: function LayoutDashboard}

Refs: apps/portal-shell/ARCHITECTURE.md §7.2 AppFrame, §10 P1-1,
      §11.7 red line #5 (fail-closed identity).
2026-07-22 12:47:00 +08:00
SpecialX
98058eb16b feat(portal-shell): wire dashboards to real data-ana queries (P1-2)
- add dashboard.graphql.ts with 6 real aggregate queries
  (teacherDashboard / studentDashboard / parentDashboard /
   adminDashboard / warnings / errorBookStats), snake_case aligned
- add dashboard.ts with 6 hooks + full domain model types
- add 4 role dashboard pages (teacher/student/parent/admin)
  using DashboardShell + StatCard + DashboardSection with
  loading / error / success tri-state
- update [[...route]]/page.tsx to redirect /shell -> /shell/{role}
- retire 6 fake contract queries and hooks (grades/homeworks/
  schedule/attendance/exams/announcements) and mark widget
  placeholders as migrated
- update universal.test.ts to drop retired hook tests
- mark ARCHITECTURE.md P1-2 as completed with acceptance evidence
2026-07-22 12:33:22 +08:00
SpecialX
f92fdf8efe feat(portal-shell): wire AppFrame + navigation + permission filter (P1-1)
实现 ARCHITECTURE.md §10 P1-1:
- 新增 src/shared/lib/navigation.ts:27 项静态导航注册表,
  按 teacher/student/parent/admin 四角色分区,group 字段用于角色过滤
- 新增 src/app/shell/layout.tsx:RSC AppFrame,从 headers() 读取
  middleware 注入的身份头,batchCheckRoutePermission 按位图二次过滤
- 新增 src/shared/components/layout/user-menu.tsx:顶部用户菜单,
  显示 userId + role,登出 POST /api/auth/logout
- 新增 src/shared/lib/__tests__/navigation.test.ts:P1-1 验收单测
  (7 用例:href 登记一致性 + 4 角色隔离 + 权限检查)
- 修改 src/shared/lib/route-permissions.ts:补全 7 个列表页根路由
  的 EXACT 登记(/shell/admin/announcements、/shell/admin/classes、
  /shell/teacher/exams 等),与 PREFIX 表互补避免 catch-all 拒绝

验收:
- vitest run navigation → 7/7 passed
- tsc --noEmit 通过;eslint(5 文件)通过

回填 ARCHITECTURE.md §10 P1-1 状态为 ,附验收证据。

关联:ARCHITECTURE.md §7.2 AppFrame / §10 P1-1
2026-07-22 12:17:16 +08:00
SpecialX
dc13a2afb4 docs(portal-shell): backfill ARCHITECTURE.md §10 P0 completion status
- §10 P0 表格新增"状态"列,全部标记 
- 新增"P0 验收证据(2026-07-22)"小节,逐条附命令/文件引用
- 文档头部状态行从"待实施"改为
  "P0 已完成 + P1 进行中"
- 验收依据:
  P0-1 login API 401 实测
  P0-2 middleware + forbidden page 代码
  P0-3 localStorage 仅匹配删除注释
  P0-4 getDefaultConfig 实现
  P0-5 instrumentation register() 实现
  P0-6 vitest 27/27 通过
  P0-7 eslint 0 errors
  P0-8 git status 干净
2026-07-22 11:42:02 +08:00
SpecialX
cfb7b005fd feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy
- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档
  涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、
  §6 部署、§10 P0-P3 验收清单
- 新增 middleware.ts:认证 + 路由门禁
  httpOnly cookie edu_session(JWT)读取
  DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production &&
  NEXT_PUBLIC_DEV_MODE=true)
  生产模式 jose JWKS RS256 验签(iss/aud 校验)
  路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头
  /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden
- 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false
  防止生产环境误开 DEV_MODE 合成身份
- 新增 app/api/auth/login/route.ts + logout/route.ts
  登录走 api-gateway /v1/iam/login
  设置 httpOnly + Secure + SameSite=Strict cookie
- 新增 app/api/graphql/route.ts:同域 GraphQL 代理
  转发到 apollo-router,注入 router-authorization 头
- 新增 app/login/page.tsx + login-form.tsx
  zod 表单校验,next 参数支持
- 新增 app/shell/forbidden/page.tsx:403 页面
- 更新 route-permissions.ts:补全 P0 路由权限映射
- 更新 permission-bitmap.ts(shared-ts):位图编码/解码
- 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理
- 更新 config-fetcher.ts:config-service 直连降级
- 更新 ApolloProvider.tsx:SSR/RSC 兼容
- 更新 eslint.config.js:design-tokens/no-hardcoded-fonts
  白名单调整
2026-07-22 11:35:36 +08:00
SpecialX
682f323bad chore(infra): docker dev-apps stack + apollo-router v1.45 compat
- 新增 docker-compose.dev-apps.yml:7 个应用服务容器化
  (iam/classes/core-edu/content/msg/push-gateway/api-gateway)
  + apollo-router override
- 新增 apollo-router dev-apps-entrypoint.sh / dev-apps-supergraph.yaml
  仅等待 5 个子图(iam/core-edu/content/msg/config-service)
  避免等待未启动的 ai/data-ana
- router.yaml:移除 v1.45 不兼容键
  (require_manifest/manifest_path/max_cost/max_batch_size)
  移除 ELv2 受限特性(persisted_queries/limits.max_depth)
  自托管无 GraphOS license
- docker-compose.yml:config-service 添加 NODE_ENV=production
  修复 pino-pretty devDep 误用
  apollo-router 添加 APOLLO_PERSISTED_QUERIES env
- services/{classes,core-edu,content,config-service}/Dockerfile
  重写为 repo 根 context + shared-ts/shared-proto 工作区依赖
  + --ignore-scripts
  修复 ERR_PNPM_WORKSPACE_PKG_NOT_FOUND 与 ERR_PNPM_IGNORED_BUILDS
- api-gateway healthcheck:wget --spider 改为 wget -O /dev/null
  修复 busybox 对 200+body 误报 exit 8
2026-07-22 11:35:02 +08:00
SpecialX
f586a0b19e feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换)
- bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground
- 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌)

P2: v2.0 新增组件单元测试补齐(5 文件 81 用例)
- permission-bitmap: 24 用例(含 GRADE_READ 重复去重)
- route-permissions: 26 用例(4 张表优先级 + AND/OR 语义)
- notify: 12 用例(sonner toast 双重性质 vi.hoisted mock)
- use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock)
- plugin-boundary: 10 用例(错误边界 + 骨架变体)

P3: 错误上报端点生产替换(后端 /api/v1/log)
- api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回)
- main.go: 注册 POST /api/v1/log 路由
- useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log)

P4: E2E 测试(3 文件 30 用例)
- streaming: 4 用例(React 19 use() + Suspense,act 包裹 render)
- error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3)
- security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围)
- vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
2026-07-17 16:49:00 +08:00
SpecialX
9cedf0c437 feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
2026-07-17 16:10:05 +08:00
SpecialX
f7e52b5b7f docs(portal-shell): update README to v1.1 with data layer and GraphQL hardening
- 版本 1.0 -> 1.1,日期 2026-07-17
- 新增 §13 数据访问层与 GraphQL 安全栈(6 子节)
- 更新 §5/§9.6/§10/§11/§12/附录 A/B/C
- 修正 004 §16.5 测试数(admin 31->4,sidebar 5->9)
- arch:scan 通过(TS 20 模块/4803 符号)
2026-07-17 13:47:40 +08:00
SpecialX
f8db1bfe80 docs(docs): add portal-shell data layer and GraphQL hardening known-issues
§1.11 Apollo Router 新增 4 条全局经验:
- PQ Manifest + require_manifest 配置
- limits 深度/成本/批量限制
- introspection 环境变量控制
- entrypoint.sh manifest 校验

§2.17 portal-shell 新增 15 条模块经验:
- Widget 内联 gql 废弃 + 4 层数据访问分层
- graphql-codegen skipDocumentsValidation
- useNotifications 命名冲突
- APQ + PQ Manifest 生成
- Windows ESM 动态 import 路径
- apollo-router PQ manifest 挂载 + 安全限制
- Resolver @RequirePermission 字段级守卫
- TS interface 不满足 Record 约束
- parent.test.tsx 可选链
- PowerShell heredoc / commitlint 限制
2026-07-17 13:39:16 +08:00
SpecialX
bbb43a210f docs(docs): sync 004 with portal-shell data layer and GraphQL hardening
新增 §11.7 portal-shell 前端数据访问层 + GraphQL 安全栈:
- §11.7.1 四层数据访问分层(Widget → API → Operations → Hook)
- §11.7.2 GraphQL 安全栈(APQ + PQ Manifest + 深度/成本限制)
- §11.7.3 Resolver 权限守卫审计与补齐

新增 §16.5 portal-shell 数据抽象与 GraphQL 加固子阶段(M1-M4 完成)
新增 ADR-042(前端数据访问四层分层)、ADR-043(PQ Manifest + APQ 安全加固)
更新 §16.4 关联 spec 文档列表
2026-07-17 13:38:16 +08:00
SpecialX
9bee920e4d test(portal-shell): add security stack tests for PQ manifest and APQ
Covers v2.1 M3 安全加固验证:
- PQ Manifest 完整性(6 cases):DocumentNode 校验、sha256 稳定性、确定性、唯一性、manifest 文件有效性、hash 一致性
- Query depth limit(2 cases):11 层嵌套构造、合法查询构造(实际拒绝由 apollo-router limits.max_depth=10 执行)
- APQ behavior(2 cases):默认启用、NEXT_PUBLIC_APOLLO_APQ=false 关闭

测试结果:95/95 passed (85 原有 + 10 新增)
2026-07-17 13:35:37 +08:00
SpecialX
caa90eba85 feat(infra): enable apollo-router APQ + manifest + depth/cost limits
Task 14 of portal-shell data abstraction plan (M3 security).

router.yaml:
- persisted_queries: enabled + require_manifest (env-controlled)
- limits: max_depth=10, max_cost=1000, max_batch_size=5
- introspection: env-controlled (prod=false, dev=true)

docker-compose.yml:
- Mount portal-shell/public/pq-manifest.json as read-only volume
- Add APOLLO_REQUIRE_PQ_MANIFEST + APOLLO_ROUTER_INTROSPECTION env vars
- Dev defaults preserve current behavior (manifest optional)

entrypoint.sh:
- Pre-start check: if require_manifest=true, fail fast when manifest missing
- Dev mode: warn but continue when manifest absent

Production enables strict mode via env:
  APOLLO_REQUIRE_PQ_MANIFEST=true
  APOLLO_ROUTER_INTROSPECTION=false
2026-07-17 13:32:46 +08:00
SpecialX
b30d43f983 feat(portal-shell): enable Apollo Client APQ + PQ manifest generator
Task 12-13 of portal-shell data abstraction plan (M3 security).

APQ (Automatic Persisted Queries):
- Add createPersistedQueryLink with sha256 to apollo-client.ts
- Production: client sends only query hash, not plaintext query
- Dev: NEXT_PUBLIC_APOLLO_APQ=false to disable for debugging
- Prevents attackers from crafting arbitrary queries via DevTools

PQ Manifest generator:
- New scripts/generate-pq-manifest.ts iterates operations barrel
- Outputs public/pq-manifest.json (sha256 -> query text whitelist)
- prebuild hook: codegen + generate-pq-manifest before next build
- 51 queries currently registered

- crypto-hash dependency added
- typecheck + lint (0 errors) + test (85/85) verified
2026-07-17 13:31:11 +08:00
SpecialX
1b5781bf42 fix: add missing @RequirePermission decorators
Adds @RequirePermission to 19 TS GraphQL resolvers across 5
subgraphs (iam, config-service, core-edu, content, msg) per
audit report §6.1. Maps: iam user/role -> IAM_USER_READ;
config-service 5 queries -> CONFIG_USER; core-edu classInfo ->
CLASS_READ, exam -> EXAM_READ, grade -> GRADE_READ, homework
-> HOMEWORK_READ, datascope visibleGrades/visibleExams ->
GRADE_READ/EXAM_READ; content chapter/knowledgePoint/question/
textbook -> CONTENT_*_READ; msg notifications ->
MSG_NOTIFICATION_READ, template -> MSG_NOTIFICATION_MANAGE.
Federation resolveReference left unguarded. Python subgraphs
(data-ana, ai) deferred to follow-up infrastructure work.
2026-07-17 13:26:58 +08:00
SpecialX
315b954998 docs(docs): add GraphQL @auth audit report
Audits 50 resolvers across 8 Apollo Federation subgraphs (iam,
config-service, classes, core-edu, content, msg, data-ana, ai).
Coverage: 18 guarded, 32 missing (36%). TS subgraphs: 35 total,
16 guarded, 19 missing (45.7%). Python subgraphs: 15 total, 2
guarded, 13 missing (13.3%). Documents AuthMiddleware /graphql
coverage gaps and Python resolver permission infrastructure as
follow-up items.
2026-07-17 13:20:30 +08:00
SpecialX
2910a90271 feat(portal-shell): extract domain API layer and migrate 31 widgets
Task 4-10 of portal-shell data abstraction plan (M1-M2).

Add 7 domain API modules under src/lib/api/ (parent/admin/teacher/
student/universal/sidebar/topbar), each exposing semantic hooks that
wrap useWidgetQuery/useWidgetMutation and return flattened domain
models. Widget code now imports from @/lib/api instead of inlining
gql literals.

- 31 widgets migrated (gql literal count in widgets: 0)
- 7 test files (85 cases, all passing)
- topbar.useNotifications renamed to useNotificationBell to avoid
  barrel export collision with universal.useNotifications
- typecheck + lint (0 errors) + test (85/85) verified
2026-07-17 13:07:24 +08:00
SpecialX
f623dcf4a7 feat(portal-shell): extract gql documents to operations layer
M1 Task 3: 从 31 widget 抽取 51 个 gql 文档到 7 个 operations 文件

- universal(7) + sidebar(3) + topbar(3) + teacher(6) + student(8) + parent(4) + admin(20) = 51 DOC

- operations/index.ts barrel 统一出口

- codegen.yml 启用 documents + skipDocumentsValidation(services 子图字段待补齐)

- 生成 types.ts (28KB) + operations.ts (10KB)
2026-07-17 12:37:36 +08:00
SpecialX
7c234947e1 feat(portal-shell): add graphql-codegen configuration
M1 Task 2: 配置 graphql-codegen 与 federation schema 预处理

- codegen.yml: schema 从 combined-schema.graphql 读取(federation 已剥离)

- scripts/normalize-schema.ts: 把 7 个子图的 extend type Query 合并为 type Query

- package.json: 新增 codegen/codegen:watch scripts + 4 个 codegen deps + tsx

- .gitignore: 忽略 src/lib/api/__generated__/

- documents 配置暂注释,Task 3 创建 operations 文件后启用
2026-07-17 12:12:37 +08:00
SpecialX
989603e318 feat(portal-shell): add lib/api skeleton with errors and types
M1 Task 1: 创建 4 层数据抽象层的骨架文件

- errors.ts: ApiError + GraphQLErrorCode 枚举

- types.ts: Pagination / PaginatedResult / UseQueryResult / UseMutationResult

- internal.ts: normalizeError 把 ApolloError 转为 ApiError

- index.ts: barrel 出口
2026-07-17 12:06:01 +08:00
SpecialX
117c89396d docs(portal-shell): add implementation plan for data abstraction & GraphQL hardening
- Plan: 20 tasks across M1-M4 phases

- Spec: fix useWidgetMutation destructure (object, not array)
2026-07-17 12:03:03 +08:00
SpecialX
135efa5ee5 docs(portal-shell): add data abstraction and GraphQL hardening spec
设计 portal-shell 数据抽象层与 GraphQL 安全加固方案:

- 4 层数据访问分层(Widget -> API -> Operations -> Hook)

- 31 个 widget 全量迁移到 lib/api/ 抽象层

- graphql-codegen 集成,消除手写类型

- Apollo Router 持久化查询(APQ + manifest)防查询探测

- 深度/复杂度限制(max_depth=10, max_cost=1000)

- 8 个子图字段级 @auth 审计与补齐

关联:portal-shell spec v2.1、004 §16、project_rules §3.8/§4
2026-07-17 11:56:35 +08:00
SpecialX
80e6468d62 docs(docs): sync 004 to v2.1 with apollo federation and portal-shell 2026-07-15 08:32:12 +08:00
SpecialX
83b6919185 feat(api-gateway): retire teacher/student/parent-bff, route GraphQL to apollo-router
v2.1 M9+M10: BFF layer and old portals retired.

- api-gateway: remove TeacherBffURL/StudentBffURL/ParentBffURL config

- api-gateway: add ApolloRouterURL config and /api/graphql route

- api-gateway: /api/admin/graphql now proxies to apollo-router

- api-gateway: health checks now ping apollo-router instead of BFF

- deploy.yml: replace teacher-bff service block with apollo-router

- deploy.yml: add config-service service block (M3 dependency)

- deploy.yml: remove teacher-portal and admin-portal service blocks

- source code in services/{teacher,student,parent}-bff/ and apps/*-portal/ retained for rollback
2026-07-15 08:14:24 +08:00
SpecialX
514e26ebb4 feat(portal-shell): implement portal-shell with apollo-router integration
M8: portal-shell unified frontend shell (Modular Monolith + micro-kernel).

- Apollo Client -> apollo-router (port 4010, RSC prefetch)

- 5 layouts: classic/focus/split/triple/canvas

- Registry + PluginLoader (dynamic import ssr:false)

- 3-layer props merge, Zustand PluginStore

- 4 widgets: grades/notification-bell/user-menu/class-selector

- config-service: new pluginConfig GraphQL resolver

- apollo-router: CORS + header propagation for portal-shell

- docker-compose.yml: portal-shell service block
2026-07-15 08:06:09 +08:00
SpecialX
47e950c664 feat(ai): temporal worker for lesson plan workflow
- deploy temporal server (postgresql + auto-setup + ui) in docker-compose
- new temporal/ module: workflow + activities + worker manager
- convert lesson plan 4-step orchestration to temporal workflow
- activities wrap existing analyze/recommend/generate/prepare_review steps
- worker injects failover_chain/content_client/data_ana_client via module globals
- start() uses temporal client.start_workflow, falls back to asyncio in dev
- register temporal ports 7233/8085 in port-allocation

Implements M6.5 of v2.1 migration plan (ADR-030).
2026-07-15 02:34:34 +08:00
SpecialX
ce5aeec955 refactor(ai): stateless workflow store with redis-only mode
- remove in-memory fallback (ADR-028: ai must be stateless)
- Redis is the only state store, shared across instances
- Redis unavailable raises RedisError instead of degrading to memory
- key prefix workflow -> ai:workflow (spec 4.3)
- TTL 24h -> 1h (spec 4.3: long-lived state managed by Temporal)

Implements M6 of v2.1 migration plan.
2026-07-15 02:22:28 +08:00
SpecialX
1423a0b8eb feat(core-edu): dataspcope @requires with scopetoken resolution
- new DataScopeResolver extends iam UserDataScope via @extends
- visibleGrades @requires(studentScopeToken) + visibleExams @requires(classScopeToken)
- resolve ScopeToken via Redis sMembers to actual ID list (ADR-041)
- add findByStudentIds/findByClassIds/findAll to repositories
- ALL token = full visibility (admin), skip WHERE IN filter

Implements M4 of v2.1 migration plan.
2026-07-15 02:20:15 +08:00
SpecialX
1a5fa78fa6 feat(config-service): split config-service from iam for plugin/layout config
- new NestJS service on port 3011/gRPC 50059 (ADR-026)
- owns 6 config_ tables (plugin/role-mapping/role-layout/layout-tpl/user-override/outbox)
- GraphQL Federation 2 subgraph with DataLoader + RouterAuthGuard
- gRPC ConfigService + admin REST CRUD + user REST API
- three-layer merge: registry.defaultProps + roleMapping.widget_props + userOverride.props
- Redis cache with 5min TTL
- registered in apollo-router supergraph + docker-compose + port-allocation

Implements M3 of v2.1 migration plan.
2026-07-15 02:13:03 +08:00
SpecialX
163bff6666 feat(infra): apollo router deployment and supergraph composition
M2: Apollo Router replaces BFF manual aggregation (ADR-037)

- Dockerfile: self-contained with rover + router, auto-compose on startup

- router.yaml: CORS, Router-Authorization header injection, traffic shaping

- supergraph.yaml: 6 subgraphs (iam/core-edu/content/msg/ai/data-ana)

- docker-compose: apollo-router on port 3000, depends on all subgraphs

- ROUTER_AUTH_SECRET added to secrets.example.env

- port-allocation.md: apollo-router registered on port 3000
2026-07-15 01:32:14 +08:00
SpecialX
6af1aa0d82 feat(push-gateway,msg): redis pubsub backplane for real-time notifications
M7: ADR-040 Redis Pub/Sub as state routing backplane

- push-gateway: remove Kafka consumer, add SSE endpoint

- SSE: subscribe to Redis user:{userId}:notify on connect

- msg: publish notifications to Redis Pub/Sub instead of HTTP push

- docker-compose: remove Kafka env from push-gateway
2026-07-15 01:28:55 +08:00
SpecialX
a75527be80 feat(content): eager invalidation and optimistic lock for cqrs
M4: ADR-038 Eager Invalidation + ADR-039 Optimistic Lock

- EagerInvalidationService: Redis DEL after MySQL commit

- Cache key conventions for textbook/chapter/kp/question

- Version header check (If-Match/X-Expected-Version) on write endpoints

- 409 Conflict on version mismatch

- All write endpoints return updatedAt timestamp
2026-07-15 01:28:20 +08:00
SpecialX
47a062606f refactor(shared-ts,iam,core-edu,content,msg): remove outbox polling publisher
M8: Debezium CDC now handles outbox table to Kafka (ADR-032)

- Remove OutboxPublisher class from shared-ts

- Remove publisher from iam/core-edu/content/msg lifecycle and modules

- OutboxService retained for transactional outbox table writes

- Debezium monitors binlog and pushes to Kafka automatically
2026-07-15 01:27:45 +08:00
SpecialX
a3f4fd013e feat(data-ana): python graphql federation subgraph with strawberry
- strawberry-graphql[asgi] dependency added

- 13 Federation 2 types: ClassPerformance/StudentWeakness/Dashboards/Mastery/ErrorBook

- 11 Query resolvers delegate to existing analytics/mastery services

- RouterAuthMiddleware validates Router-Authorization header on /graphql

- GraphQL endpoint mounted at /graphql alongside existing REST endpoints
2026-07-15 00:57:02 +08:00
SpecialX
1dcdcf23fd feat(ai): python graphql federation subgraph with strawberry
- strawberry-graphql[asgi] dependency added

- GeneratedReport and LessonPlanStatus @key types with resolve_reference

- RouterAuthMiddleware validates Router-Authorization header on /graphql

- GraphQL endpoint mounted at /graphql in FastAPI app

- WorkflowStateStore injected for lesson plan status resolution
2026-07-15 00:56:37 +08:00
SpecialX
35aa56537d chore(ai): add proto-generated graphql schema reference
Python service needs strawberry-graphql federation implementation
2026-07-15 00:20:21 +08:00
SpecialX
d3b126ed9d feat(msg): graphql subgraph with dataloader for notification/template
- GraphQLModule: Apollo Federation 2 at /graphql

- Notification/NotificationTemplate @key with @ResolveReference

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- batchFindByIds added to notifications and templates repositories
2026-07-15 00:19:26 +08:00
SpecialX
d59c4e585f feat(content): graphql subgraph with dataloader for textbook/chapter/kp/question
- GraphQLModule: Apollo Federation 2 at /graphql

- Textbook/Chapter/KnowledgePoint/Question @key with @ResolveReference

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- batchFind methods added to 4 repositories

- Domain modules export repositories for GraphqlModule injection
2026-07-15 00:18:57 +08:00
SpecialX
9ff7a61ee2 feat(core-edu): graphql subgraph with dataloader for exam/homework/grade/class
- GraphQLModule: Apollo Federation 2 at /graphql

- Exam/Homework/Grade/ClassInfo @key with @ResolveReference using DataLoader

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- batchFind methods added to exams/homework/grades/classes repositories

- Domain modules export repositories for GraphqlModule injection
2026-07-15 00:18:30 +08:00
SpecialX
6bed673d9f feat(iam): graphql subgraph with dataloader and router auth guard
- GraphQLModule: Apollo Federation 2 subgraph at /graphql

- UserResolver/RoleResolver: @key with @ResolveReference using DataLoader (ADR-035)

- DataScopeResolver: ScopeToken for visible class/student IDs (ADR-041)

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- DataLoaderService: REQUEST-scoped userLoader + roleLoader

- IamRepository.batchFindRoles: batch query for DataLoader

- IamService.getVisibleClassIds/getVisibleStudentIds: dataScope calculation

- app.module.ts: register PermissionGuard + RouterAuthGuard as APP_GUARD
2026-07-14 23:48:16 +08:00
SpecialX
5fcb831a18 feat(shared-ts): federation shared utilities for apollo subgraphs
- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- DataLoader factory: request-scoped batching (ADR-035)

- ScopeTokenService: Redis-backed scope token (ADR-041)

- GraphqlContext: build context from HTTP headers

- FederationExceptionFilter: HTTP-to-GraphQL error mapping
2026-07-14 23:46:51 +08:00
SpecialX
3c2ea50c7f feat(infra): proto-to-graphql generator + debezium outbox connector
- M0: parse proto to generate Federation 2 subgraph SDL

- M0.5: Debezium Connect monitors 4 outbox tables

- docker-compose: extend Debezium profiles to default+p3+p4+p5+p6

- package.json: add proto:gen-graphql script
2026-07-14 23:45:32 +08:00
SpecialX
f212ba0813 docs(docs): known-issues ai 模块经验更新(9 RPC + 6 权限点 + 2 新经验)
gRPC server 8→9 RPC(新增 GenerateReport)

PermissionGuard 5→6 权限点(新增 ai:report:generate)

新增经验:Markdown 章节关键词提取 + main.py 模块级变量顺序
2026-07-14 23:02:01 +08:00
SpecialX
765f7da4c0 feat(msg): v2 ARB-013 topic 命名统一 + 考试事件消费
ARB-013 P0 修复:PRODUCER_TOPIC_MAP 从 edu.notification.* 改为 edu.notify.notification.*

kafka.consumer 新增 3 考试实时事件消费(exam.extended/force_submitted/question_reordered)

嵌套 payload 解包支持 + topic-map 扩展

新增 6 测试数据文件(docker-notify + 5 kafka 事件 json)

101 单元测试通过 + Docker 真实环境验证
2026-07-14 23:00:33 +08:00
SpecialX
ad39a3bb0f feat(iam): v2 用户管理 RPC + F12 httpOnly Cookie
admin-portal §2.3 P1 阻塞项补齐:CreateUser/UpdateUser/DeleteUser 3 RPC

iam.repository/service/grpc.controller 实现 3 用户管理方法(含 bcrypt + 审计)

iam.controller 新增 POST /v1/iam/users + DELETE /v1/iam/users/:id(@RequirePermission(IAM_USER_MANAGE))

iam.dto 新增 createUserSchema Zod 校验

F12 httpOnly Cookie:refresh_token 改为 httpOnly+Secure+SameSite=Strict Cookie 下发

extractRefreshToken 优先读 cookie 回退 body + buildRefreshTokenCookie/buildClearCookie
2026-07-14 22:59:28 +08:00
SpecialX
d11441c9a8 feat(core-edu): v2 P3.14 考试实时事件 + pino 修复
新增 3 RPC:ExtendExam/ForceSubmitExam/ReorderExamQuestions

新增 3 Kafka 事件:exam.extended/exam.force_submitted/exam.question_reordered

exams.service.ts 新增 3 方法 + Outbox 事务内写入 + TOPIC_MAP 映射

grpc.server.ts 注册 3 handler + grpc-smoke 测试

logger.ts pino 导入修复(import pino → import { pino })

27/27 smoke test 通过
2026-07-14 22:58:38 +08:00
SpecialX
aac26c7c6f feat(ai): v2 新增 GenerateReport RPC + ReportService
第 9 个 RPC GenerateReport(学情报告生成):data-ana 学情数据 → LLM 生成 → 结构化提取

新增 ReportService 业务编排层 + GenerateReportRequest/GeneratedReport 模型

gRPC servicer + HTTP POST /v1/ai/generate/report(权限 ai:report:generate)

proto_gen 重新生成 + 测试覆盖(servicer/service/HTTP/模型/权限 共 26 用例)

402 测试通过,覆盖率 88.5%
2026-07-14 22:57:57 +08:00
SpecialX
843b370b3d feat(shared-proto): ai/core_edu/iam proto v2 扩展
ai.proto: 新增 GenerateReport RPC + GenerateReportRequest/GeneratedReport message

core_edu.proto: 新增 ExtendExam/ForceSubmitExam/ReorderExamQuestions 3 RPC + message

iam.proto: 新增 CreateUser/UpdateUser/DeleteUser 3 RPC + 4 message
2026-07-14 22:57:18 +08:00
SpecialX
7545956202 docs(docs): v2.1 架构 spec 8 项调整
- Temporal 严格边界引入(AI 工作流 + Saga,CRUD 短事务禁用)

- CDC+Outbox 结合(Debezium Transaction Log Tailing,废弃轮询)

- DataLoader 强制(@key 解析器请求合并,消除 N+1)

- Router-Authorization 信任凭证(拒绝非 Router 的 GraphQL 请求)

- 外部 GraphQL + 内部 gRPC 边界明确

- Eager Invalidation + 乐观锁版本号回传

- Redis Pub/Sub 推送背板(边缘网关不挂 Kafka)

- ScopeToken 优化大规模 ID 列表传递
2026-07-14 22:55:56 +08:00
SpecialX
62682b9d61 docs(docs): 004 arch.db 二次校验 + 新增 v2 架构重设计 spec
004 修正 7 处与代码不符描述:

- proto 统计 / 包名 / core-edu gRPC service 数

- msg RPC 数 / data-ana RPC 数

- student-bff 模块数 / parent-bff 模块数

新增 v2 架构重设计 spec(996 行):

- Apollo Federation BFF 联邦

- DataScope @requires 运行时解析

- iam 拆分 config-service

- content CQRS / ai 无状态化

- SSE 优先 / Temporal 不引入

Spec 自审修复 6 处问题:

- apollo-router 端口冲突 4000→4011

- Kafka topic 命名一致性

- CDC/Outbox 投影器职责分工

- 改动点数字 / 服务数 / 容器数计算
2026-07-14 21:25:37 +08:00
SpecialX
594a4e65fe fix(arch-scan): 修复 ts-scanner 误识别 Python 服务 + 扩展符号提取
- 新增 pyproject.toml 检测,跳过纯 Python 服务(ai/data-ana)和包(shared-py)

- 扩展符号提取:新增 TS_CONST_EXPORT_RE 匹配 camelCase/PascalCase 导出常量

- 新增 TS_TYPE_RE 匹配 export type alias

- 修复后 arch:scan 输出 22 模块 / 4715 符号 / 475 契约
2026-07-14 21:24:22 +08:00
SpecialX
0b858d9069 docs(docs): 新增 0020 Portal Shell 架构文档(C4+4+1+ADR) + 设计 spec v2.1 + 更新 0010/004 指向新架构 2026-07-14 18:34:40 +08:00
SpecialX
9db7fd917e feat(data-ana): v2 P6 硬化完成 + 6 新 RPC + Prometheus 监控
P6 硬化(5 项全部完成):

- CDC 多实例水平扩展: _INSTANCE_ID + get_lag() 真实 lag 计算

- ExamCache Redis 化: key data_ana:exam:{exam_id}, TTL 30 天 + 内存 LRU fallback

- ClickHouse TTL 归档: 5 表均加 TTL(1-3 年),分区级删除

- Prometheus 监控: 18 个指标(CDC/CH/ExamCache/DataScope/gRPC/业务)

- readyz 深度硬化: 4 依赖超时检查(CH 1s/Redis 200ms/iam 2s/CDC lag<1000)

v2 新增 6 个 RPC(analytics.proto 扩展为 18 RPC):

- GetStudentGrowth / GetAssignmentAnalysis / GetMasterySummary

- ListDiagnosticReports(占位,待 ai 服务)/ ListErrorBookItems / GetErrorBookStats

监控与可观测性: lifespan 预热 + gRPC ServerInterceptor + CDC 消费者指标

Docker 本地测试 19 项全部通过(healthz/readyz/metrics + 11 HTTP + 10 gRPC + ruff)

nextstep-v2.md: 上游需求对齐 + 下游要求(iam/core-edu/content/ai/SRE)
2026-07-14 18:07:17 +08:00
SpecialX
78e406b317 feat(content): v2 扩展 Elective/LessonPlan/CoursePlan 三业务域
新增 3 个业务域(10 RPC):
- ElectiveService: 选修课列表/学生选课记录/选课/退课(含容量与重复校验)
- LessonPlanService: 教师备课列表/学生备课列表(仅 published)/详情
- CoursePlanService: 学生课程计划列表/详情
- KnowledgeGraphService.GetKnowledgePath: 与 GetLearningPath 同实现

新增 4 张 MySQL 表(elective_courses/selections/lesson_plans/course_plans),含完整索引。

新增 11 个权限点,覆盖 admin/teacher/student/parent 四角色。

proto 由 4 Service/22 RPC 扩展至 7 Service/32 RPC,v1 全部 RPC 保持向后兼容。

修复 logger.ts pino 导入: default import 在 NodeNext ESM 下不可调用,
改用 named import(与 iam/msg/core-edu 对齐)。

Docker 本地测试全部通过(HTTP + gRPC 双协议),健康检查、
Elective/LessonPlan/CoursePlan CRUD、4 个新 gRPC Service 全部验证通过。

nextstep-v2.md 已创建,记录上下游依赖与 6 项联调待办。
2026-07-14 17:54:37 +08:00
626 changed files with 73485 additions and 4430 deletions

View File

@@ -1,4 +1,4 @@
module.exports = {
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
@@ -12,8 +12,9 @@
[
'api-gateway', 'push-gateway',
'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai',
'config-service',
'teacher-bff', 'student-bff', 'parent-bff',
'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal',
'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal', 'portal-shell',
'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens',
'arch-scan', 'infra', 'docs', 'deps', 'release',
],

View File

@@ -49,6 +49,13 @@ jobs:
- name: Typecheck
run: pnpm -r run typecheck
- name: Portal-shell structural checks (P1-8)
working-directory: apps/portal-shell
run: |
pnpm run check:routes
pnpm run check:pages
pnpm run check:codegen
- name: Test
run: pnpm -r run test
continue-on-error: true # P6: 部分服务无 test 脚本,待补全

3
.gitignore vendored
View File

@@ -11,6 +11,9 @@ out/
target/
bin/
obj/
# Allow Next.js app router route segments named "build" (e.g. exams/[id]/build)
!apps/portal-shell/src/app/**/build/
!apps/portal-shell/src/app/**/build/**
# Go
*.exe

View File

@@ -0,0 +1,29 @@
# portal-shell 环境变量模板v2.1 M8
#
# 复制为 .env.local 后按实际环境填写。
# 服务端变量不加 NEXT_PUBLIC_ 前缀;前端变量必须加。
# Apollo RouterGraphQL 联邦入口M8 验收点)
# 前端 Apollo Client 直连此地址
NEXT_PUBLIC_APOLLO_ROUTER_URL=http://localhost:3000/graphql
# 服务端 RSC 预取用(容器内走内部网络)
APOLLO_ROUTER_URL=http://localhost:3000/graphql
# Apollo Client APQ 开关v2.1 M3 安全加固)
# 生产环境必须为 true前端只发 query hash不发明文 query
# 开发环境可设为 false 便于 DevTools 调试
NEXT_PUBLIC_APOLLO_APQ=true
# config-service 直连地址(开发态降级用)
# 当 apollo-router 不可用时fetchPluginConfig 会降级直连此地址的 /graphql 端点
# 生产环境不需要配置apollo-router 必须可用)
CONFIG_SERVICE_URL=http://localhost:3011
# API GatewayJWT 校验 + 注入 x-user-id / x-user-role
NEXT_PUBLIC_API_GATEWAY_URL=http://localhost:8080
# Realtime GatewaySSE 推送)
NEXT_PUBLIC_REALTIME_GATEWAY_URL=http://localhost:8081
# 开发模式(未登录时使用 dev-user / teacher 兜底)
NEXT_PUBLIC_DEV_MODE=true

View File

@@ -0,0 +1,44 @@
/**
* ESLint Design Tokens 配置独立运行eslint -c .eslintrc.tokens.js src
*
* 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。
* 使用 typescript-eslint 包的 parser与 eslint.config.js 一致)。
* 关联project_rules §3.10
*/
import tseslint from "typescript-eslint";
/** @type {import('eslint').Linter.Config[]} */
export default tseslint.config(
{
files: ["**/*.{ts,tsx,js,jsx}"],
languageOptions: {
parser: tseslint.parser,
ecmaVersion: 2024,
sourceType: "module",
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
rules: {
"no-restricted-syntax": [
"error",
{
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
message:
"禁止硬编码颜色 #hex使用 var(--*) 或 Tailwind bg-* 类project_rules §3.10",
},
{
selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]",
message:
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)project_rules §3.10",
},
],
},
},
{
files: ["**/primitive.css", "**/manifest.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
);

17
apps/portal-shell/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# graphql-codegen 产物(构建时生成)
src/lib/api/__generated__/
# 本地环境变量(应永远在本地,不入库;根 .gitignore 已覆盖,此处冗余声明)
.env
.env.local
.env.*.local
# TypeScript 增量构建缓存286KB不应入库根 .gitignore 已 *.tsbuildinfo 覆盖)
tsconfig.tsbuildinfo
# Next.js 构建产物
.next/
out/
# 测试覆盖率
coverage/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
# 多阶段构建Next.js 生产镜像standalone 模式)
# 用法docker build -t edu/portal-shell:latest -f apps/portal-shell/Dockerfile .
# 端口规范portal-shell :4010避开旧 portal 4000-4003 段位)
# ============ Builder ============
FROM node:22-alpine AS builder
WORKDIR /app
# 启用 pnpm
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
# 先拷依赖清单,利用缓存(含 workspace 共享包)
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* tsconfig.base.json* ./
COPY apps/portal-shell/package.json ./apps/portal-shell/
COPY packages/ui-tokens/package.json ./packages/ui-tokens/
COPY packages/ui-components/package.json ./packages/ui-components/
COPY packages/hooks/package.json ./packages/hooks/
# 安装依赖(含 devDependencies构建需要
RUN pnpm install --filter @edu/portal-shell... --frozen-lockfile || pnpm install --filter @edu/portal-shell...
# 拷源码
COPY apps/portal-shell ./apps/portal-shell
COPY packages/ui-tokens ./packages/ui-tokens
COPY packages/ui-components ./packages/ui-components
COPY packages/hooks ./packages/hooks
# 构建(禁用 telemetry生产模式standalone 输出)
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @edu/portal-shell run build
# ============ Runtime ============
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=4010
# 非 root 用户运行
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# 拷 standalone 产物(已含 node_modules 和 server.js自包含
COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/public ./public
USER nextjs
EXPOSE 4010
# 健康检查(/api/health liveness 端点)
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --spider http://localhost:4010/api/health || exit 1
# standalone 模式下直接用 node server.js 启动(已自包含所有依赖)
WORKDIR /app/apps/portal-shell
CMD ["node", "server.js"]

1891
apps/portal-shell/README.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
# graphql-codegen configuration
#
# Schema source: combined-schema.graphql (generated by scripts/normalize-schema.ts
# from services subgraph SDL files, with federation `extend type Query` normalized).
#
# Subgraph list (7 with GraphQL): iam / config-service / core-edu / content /
# msg / data-ana / ai (classes has no GraphQL subgraph yet).
#
# Outputs:
# - __generated__/types.ts: all GraphQL schema types (always generated)
# - __generated__/operations.ts: DocumentNode constants emitted from
# operations/*.graphql.ts via typescript-document-nodes plugin.
# - __generated__/dashboard-types.ts: per-operation types for data-ana domain
# (skipDocumentsValidation: false). Other domains still reference forward-looking
# spec fields not yet in services subgraph SDL; they remain on the global
# skipDocumentsValidation: true. As services catch up, additional per-domain
# outputs can be added (config → core-edu → content → msg → iam order).
#
# Related: spec section 2.4 / 5.3, ARCHITECTURE.md §10 P1-7
schema:
- src/lib/api/__generated__/combined-schema.graphql
# Schema validation is skipped because services/ai subgraph has invalid input
# types (ChatRequestInput.messages references output type ChatMessage; same
# for ChatResponseInput.usage → Usage). normalize-schema.ts rewrites those
# field types to String as a codegen-only sanitize.
skipSchemaValidation: true
generates:
src/lib/api/__generated__/types.ts:
plugins:
- typescript
src/lib/api/__generated__/operations.ts:
# typescript-document-nodes only: emits typed DocumentNode constants
# from the gql templates in operations/*.graphql.ts. We deliberately
# omit typescript-operations here because portal-shell widgets use
# forward-looking spec fields (grades, myClasses, lessonPlans, etc.)
# that are not yet present in services subgraph SDL; once services
# catch up we can re-add typescript-operations for full query typing.
# Per-domain typed outputs (e.g. dashboard-types.ts) cover the
# operations whose fields DO match the schema today (P1-7).
documents: src/lib/api/operations/**/*.graphql.ts
plugins:
- typescript-document-nodes
# P1-7: data-ana domain (dashboard.graphql.ts) — skipDocumentsValidation
# closed because all 6 operations strictly match the schema. Generates
# per-operation types so lib/api/dashboard.ts can drop its hand-written
# interfaces. Config domain closure deferred until LayoutTemplateGql
# schema adds `availableSlots` (admin.graphql.ts GET_LAYOUT_TEMPLATES_DOC
# queries that field but it is not yet in the subgraph SDL).
src/lib/api/__generated__/dashboard-types.ts:
documents: src/lib/api/operations/dashboard.graphql.ts
plugins:
- typescript
- typescript-operations
config:
skipDocumentsValidation: false
config:
preResolveTypes: true
skipTypename: true
exportTypeKeyOnly: true
useTypeImports: true
# Generated operations.ts should import `gql` from @apollo/client (the
# package portal-shell actually uses) instead of the default graphql-tag.
gqlImport: "@apollo/client#gql"
# Skip document validation against schema. portal-shell widgets use spec
# forward-looking fields (grades, myClasses, lessonPlans, etc.) not yet
# implemented in services subgraph SDL. Without this flag, codegen would
# emit 44 "Cannot query Field X on type Query" errors and refuse to write
# operations.ts. The runtime apollo-router validates documents at request
# time, so skipping here only affects codegen-time type generation.
# Per-output overrides (see dashboard-types.ts above) close this flag for
# specific domains whose operations DO match the schema (P1-7).
skipDocumentsValidation: true

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/lib/utils",
"ui": "@/shared/components/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,148 @@
/**
* portal-shell ESLint flat config (ESM)
*
* 包含设计令牌强制规则project_rules §3.10
* - 禁止 #hex 颜色字面量
* - 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字体名字面量
*
* P0-7补 no-restricted-importsARCHITECTURE.md §3.4 V3-A3 / §8.3 铁律)
* - 禁 widget/页面 直接 import sonner统一走 @/shared/lib/notify
* - 禁 widget/页面 绕过 lib/api 直接 import @apollo/client
* - 禁 widget 跨目录 import 其他 widget插件隔离
*
* 关联project_rules §3.10、portal-shell ARCHITECTURE.md §8.3、§11.7
*/
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
{
ignores: [
"**/dist/**",
"**/node_modules/**",
"**/.next/**",
"**/coverage/**",
"**/*.config.js",
"**/*.config.mjs",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
},
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"no-console": "off",
},
},
// 设计令牌强制规则project_rules §3.10
{
files: ["**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-syntax": [
"error",
{
// 禁止 #hex 颜色字面量(如 "#fff"、"#000000"
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
message:
"禁止硬编码颜色 #hex使用 var(--*) 或 Tailwind bg-* 类project_rules §3.10",
},
{
// 禁止字体名字面量next/font 的 import 标识符不受影响)
selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]",
message:
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)project_rules §3.10",
},
],
},
},
// P0-7no-restricted-imports 强制ARCHITECTURE.md §8.3 / §11.7 红线)
// - 禁直接 import sonner统一走 @/shared/lib/notify 封装)
// - 禁页面/widget 绕过 lib/api 直接 import @apollo/client
// - 禁 widget 跨目录 import 其他 widget插件隔离铁律
// 白名单:
// - notify 封装本体src/shared/lib/notify.ts+ 其测试__tests__/notify.test.ts+ Toaster 容器src/shared/components/ui/sonner.tsx
// - 数据层src/lib/apollo-client.ts、useWidgetQuery.ts、useWidgetMutation.ts、config-fetcher.ts、src/lib/api/**、src/providers/ApolloProvider.tsx
// - widget 注册中心src/shell/Registry.tsx必须 import 各 widget 的 plugin.manifest是唯一例外
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/shared/lib/notify.ts",
"src/shared/lib/__tests__/notify.test.ts",
"src/shared/components/ui/sonner.tsx",
"src/lib/apollo-client.ts",
"src/lib/useWidgetQuery.ts",
"src/lib/useWidgetMutation.ts",
"src/lib/config-fetcher.ts",
"src/lib/api/**",
"src/providers/ApolloProvider.tsx",
"src/shell/Registry.tsx",
],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "sonner",
message:
"禁止直接 import sonner统一使用 @/shared/lib/notifyARCHITECTURE.md §8.3",
},
{
name: "@apollo/client",
message:
"禁止绕过 lib/api 直接 import @apollo/client使用 useWidgetQuery/useWidgetMutationARCHITECTURE.md §11.7",
},
],
patterns: [
{
group: ["@/widgets/*", "../widgets/*", "../../widgets/*"],
message:
"禁止 widget 跨目录 import 其他 widget插件隔离铁律ARCHITECTURE.md §11.7",
},
],
},
],
},
},
// 白名单令牌原始定义、PWA manifest
{
files: ["**/primitive.css", "**/manifest.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
// 测试文件放宽规则
{
files: [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/__tests__/**",
],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
prettierConfig,
);

6
apps/portal-shell/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,108 @@
/**
* Next.js 配置v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack + P1-4 next-intl + P1-5 MSW
*
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker
* - output:standalone单容器部署
* - transpilePackages: @edu/* workspace 包
* - 反向代理:/api/v1/* → api-gateway :8080JWT 校验 + 注入 x-user-id/x-user-role
* - GraphQL 查询走 apollo-router :3000M8 验收点,由 Apollo Client 直连)
* - next-intl无 i18n 路由模式locale 由 cookie 决定ARCHITECTURE.md §3.4 V3-A6
* - MSW 兜底层P1-5NEXT_PUBLIC_MSW!=1 时把 @/mocks 与 @/mocks/graphql-data
* 重定向到空 stub确保生产 bundle 不含 mock 数据ARCHITECTURE.md §3.4 V3-A7
*
* Next 16 默认 Turbopack
* - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射
* - webpack 配置保留作为 fallback--webpack flag 时生效)
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A6/V3-A7、spec §2、project_rules §3.2
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// P1-5MSW 启用开关。生产构建NEXT_PUBLIC_MSW 非等于 "1")时把
// @/mocks 与 @/mocks/graphql-data 重定向到空 stub
// 确保 bundle 不含真实 mock 数据与 msw 包。
// 注意Turbopack resolveAlias 不支持 Windows 绝对路径("windows imports
// are not implemented yet"),故使用 @/mocks/empty 说明符(由 tsconfig
// paths 解析为 src/mocks/empty.ts而非绝对路径。
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
const MOCKS_STUB_SPECIFIER = "@/mocks/empty";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
transpilePackages: [
"@edu/ui-components",
"@edu/ui-tokens",
"@edu/hooks",
"@edu/contracts",
"@edu/shared-ts",
],
experimental: {
serverActions: { bodySizeLimit: "2mb" },
},
// Turbopack 配置Next 16 默认):处理 ESM 包 .js 后缀导入源码 .ts/.tsx 文件
turbopack: {
resolveExtensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".json",
],
// P1-5MSW 关闭时把 @/mocksMswProvider 静态 import
// @/mocks/graphql-dataroute.ts 静态 import重定向到空 stub。
// 使用 @/mocks/empty 说明符作为目标Turbopack 不支持 Windows 绝对路径)。
...(MSW_ENABLED
? {}
: {
resolveAlias: {
"@/mocks": MOCKS_STUB_SPECIFIER,
"@/mocks/graphql-data": MOCKS_STUB_SPECIFIER,
},
}),
},
// Webpack 配置fallback使用 --webpack flag 时生效)
webpack(config) {
config.resolve = config.resolve || {};
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
".js": [".ts", ".tsx", ".js"],
};
// P1-5MSW 关闭时把 @/mocks 与 @/mocks/graphql-data 重定向到空 stub。
// webpack resolve.alias 支持 exact 匹配($ 后缀)与前缀匹配,
// 这里用 exact 匹配避免误伤其他 @/mocks/xxx 路径(实际只有这两个 import 点)。
if (!MSW_ENABLED) {
config.resolve.alias = {
...config.resolve.alias,
"@/mocks$": MOCKS_STUB_SPECIFIER,
"@/mocks/graphql-data$": MOCKS_STUB_SPECIFIER,
};
}
return config;
},
async rewrites() {
const gateway =
process.env.API_GATEWAY_URL || "http://localhost:8080";
return [
{
source: "/api/v1/:path*",
destination: `${gateway}/api/v1/:path*`,
},
{
source: "/api/auth/:path*",
destination: `${gateway}/api/v1/iam/:path*`,
},
];
},
};
export default withNextIntl(nextConfig);

View File

@@ -0,0 +1,83 @@
{
"name": "@edu/portal-shell",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev -p 4010",
"build": "next build",
"start": "next start -p 4010",
"lint": "eslint src",
"lint:tokens": "eslint -c .eslintrc.tokens.js src",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"codegen": "tsx scripts/normalize-schema.ts && graphql-codegen --config codegen.yml",
"codegen:watch": "graphql-codegen --config codegen.yml --watch",
"generate-pq-manifest": "tsx scripts/generate-pq-manifest.ts",
"check:routes": "tsx scripts/check-route-table.ts",
"check:pages": "tsx scripts/check-page-count.ts",
"check:codegen": "tsx scripts/check-codegen.ts",
"check:all": "pnpm run check:routes && pnpm run check:pages && pnpm run check:codegen",
"prebuild": "pnpm run codegen && pnpm run generate-pq-manifest"
},
"dependencies": {
"@apollo/client": "^3.11.0",
"@edu/hooks": "workspace:*",
"@edu/ui-components": "workspace:*",
"@edu/ui-tokens": "workspace:*",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto-hash": "^4.0.1",
"graphql": "^16.8.0",
"jose": "^5.9.6",
"lucide-react": "^0.562.0",
"msw": "2.7.0",
"next": "^16.0.10",
"next-intl": "^4.13.2",
"next-themes": "^0.4.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
"swr": "^2.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
"zustand": "^5.0.9"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.0",
"@graphql-codegen/typescript-document-nodes": "^4.0.0",
"@graphql-codegen/typescript-operations": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"jsdom": "^25.0.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.0.0",
"typescript": "^5.6.0",
"vitest": "^2.0.0"
},
"msw": {
"workerDirectory": [
"public"
]
}
}

View File

@@ -0,0 +1,11 @@
/**
* PostCSS 配置Tailwind v4
*
* Tailwind v4 使用 @tailwindcss/postcss 插件,配置通过 CSS 内的
* @import "tailwindcss" + @theme inline 指令完成,不再需要 tailwind.config.js。
*/
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,307 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.7.0'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[responseClone.body],
)
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
})
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}

View File

@@ -0,0 +1,53 @@
{
"cc22a52e830be26094f7e628b84ae378b2956adb229e9a52249a660d14b7571a": "query GetAnnouncements($limit: Int) {\n announcements(limit: $limit) {\n id\n title\n body\n author\n publishedAt\n }\n}",
"993d23648ccd8b44bc39189067720276325235cb5d7d26c78d78986487c8bbc9": "query GetAttendance($classId: ID!, $termId: ID!) {\n attendance(classId: $classId, termId: $termId) {\n present\n absent\n late\n total\n }\n}",
"87bd02d956d7c16182aa0fc4817ea568b6e6ef922fd7891d0eece8d5db2e0caa": "query GetExams($classId: ID!, $limit: Int) {\n exams(classId: $classId, limit: $limit) {\n id\n name\n examDate\n subject\n maxScore\n }\n}",
"4fffc072f251c9b9cff1306d3f36f89af726c49aa5d898b2701389a864f4b0ca": "query GetGrades($classId: ID!) {\n grades(classId: $classId) {\n studentId\n score\n }\n}",
"6ef5a84c342249898afc04be737102bd48be909244f88e7b36acff046e3fda66": "query GetHomeworks($classId: ID!, $limit: Int) {\n homeworks(classId: $classId, limit: $limit) {\n id\n title\n dueDate\n status\n }\n}",
"77a4840fce570fdd776eadee84af6c9196491661287a5f5c1ab710e11a6bed19": "query GetNotificationsList($limit: Int, $offset: Int) {\n notifications(limit: $limit, offset: $offset) {\n items {\n id\n title\n body\n createdAt\n type\n }\n total\n }\n}",
"bb5d9a0a34c299962fe296cfd7eea62ba501cb77c425403d045bbb90efb200a2": "query GetSchedule($classId: ID!, $dayOfWeek: Int) {\n schedule(classId: $classId, dayOfWeek: $dayOfWeek) {\n id\n subject\n startTime\n endTime\n teacherName\n }\n}",
"6e5b7232d2aec0c106ead6b57a1aff4f1cf5413459b5dca63220ba165b25e37d": "query GetMyChildren {\n myChildren {\n id\n name\n grade\n className\n }\n}",
"4bceb2c86a41b705c0e3df5af592716e4b93706a2f1501f91657bbd255e65624": "query GetMyClasses {\n myClasses {\n id\n name\n }\n}",
"683b1379ab15ad07c07f1ab64906a904c31f639f2e1f302af263529048d1348d": "query GetTerms {\n terms {\n id\n name\n startDate\n endDate\n isActive\n }\n}",
"39bdf6630ce77deed9582b72645ba14cab60874c6f2da28baee676e62e2025bc": "query GetCurrentUser {\n me {\n id\n name\n email\n role\n }\n}",
"006af776ff6266b70ef5733f834ef3e33a1b88339a7b7319a76c56bb71a8e89e": "query GetNotifications($limit: Int) {\n notifications(limit: $limit) {\n id\n title\n }\n}",
"5afce24b57e8e3db709eab2ca43d19e8fbb893d398a735ba004b0ed07649ad14": "query Search($keyword: String!, $limit: Int) {\n search(keyword: $keyword, limit: $limit) {\n id\n type\n title\n subtitle\n }\n}",
"a4c3aac21ef49c7c29303f8ff84da0966f896dbaed20fa84936129d864352930": "query GetLessonPlans($classId: ID!, $unitId: ID) {\n lessonPlans(classId: $classId, unitId: $unitId) {\n id\n title\n objectives\n content\n resources\n }\n}",
"10f2d92cf392929afbb5c5bc001d37bd9c51fb37eff0383b11edd23f03a4d007": "query GetQuestions($bankId: ID!, $type: String, $limit: Int) {\n questions(bankId: $bankId, type: $type, limit: $limit) {\n id\n type\n difficulty\n content\n options\n answer\n tags\n }\n}",
"8d4c0ebf844b1790b356a0b6d70899741f7271707d214fc79da0e797764f3b48": "query GetSchedulingRules($classId: ID!) {\n schedulingRules(classId: $classId) {\n id\n dayOfWeek\n periods\n subject\n teacherId\n room\n }\n}",
"198a9e4bc14a6f2acfb2f0ed82e993ef0ee0bd05fe84c944397a356445985785": "query GetTextbooks($subjectId: ID, $grade: String) {\n textbooks(subjectId: $subjectId, grade: $grade) {\n id\n title\n author\n publisher\n isbn\n chapters {\n id\n title\n }\n }\n}",
"34a207efda8909c8906b2bd1134c1c660b515d1787c7f1fcffc1b7f8a0471dc5": "mutation SaveLessonPlan($input: LessonPlanInput!) {\n saveLessonPlan(input: $input) {\n id\n }\n}",
"751e8daa3eb334f5080bd8f88c00d1df4bfe4fb1d7093320d4be675ef95f62e5": "mutation UpdateSchedulingRule($id: ID!, $input: SchedulingRuleInput!) {\n updateSchedulingRule(id: $id, input: $input) {\n id\n }\n}",
"3600a257d1f00bc7590545b7ac090e60498a90b633c50336476c13feb20f3839": "mutation DropCourse($courseId: ID!) {\n dropCourse(courseId: $courseId)\n}",
"eb9bd2e6ce678322ffa50cdc7480385b33eb5b86d0ebebb0b08650af9d067704": "mutation EnrollCourse($courseId: ID!) {\n enrollCourse(courseId: $courseId)\n}",
"c52d26c0ef308f029b5efd53598b9d8ecc483d293297167abeccab29fd345aff": "query AiTutorSessions($limit: Int) {\n aiTutorSessions(limit: $limit) {\n id\n title\n lastMessage\n updatedAt\n }\n}",
"0a2fdb7cfac1df1fd794a6aa03b2e81741bfc1a63ababe860a85cece89e1c825": "query ElectiveCourses($termId: ID!) {\n electiveCourses(termId: $termId) {\n id\n name\n teacher\n capacity\n enrolled\n schedule\n credits\n category\n }\n}",
"ee35ac1fb866bb2919be69e3bc52b276bda5be85543d2d5238e482a1db63681f": "query MyErrorBook($subjectId: ID, $limit: Int) {\n myErrorBook(subjectId: $subjectId, limit: $limit) {\n id\n question\n myAnswer\n correctAnswer\n errorCount\n lastErrorAt\n subject\n }\n}",
"736e74c878d89645df6867a84a64fa533a42c996d3fa946ff108afc3aa56bdfe": "query MyLearningPath($subjectId: ID!) {\n myLearningPath(subjectId: $subjectId) {\n nodes {\n id\n title\n type\n status\n dependencies\n }\n progress\n }\n}",
"689bf7df7102fd09515c3fafdb1a3e8b2c38ed3c19b81d20f266fbe32a492325": "mutation MarkErrorMastered($id: ID!) {\n markErrorMastered(id: $id)\n}",
"7c3e2df6ae1ea41af2b6c994f073ded78541e3e42de8e6cc68f186bcd3c5998b": "mutation SendAiTutorMessage($sessionId: ID, $message: String!) {\n sendAiTutorMessage(sessionId: $sessionId, message: $message) {\n sessionId\n reply\n }\n}",
"3d43fd71c95d9b654c786a98a0e531d18b81ae113655de4da52343562eab9a11": "mutation ApproveLeave($id: ID!) {\n approveLeave(id: $id) {\n id\n status\n }\n}",
"61a7f7edcaaebdd90f27f61d2b0d7e0ffa6593accbc51b1411bee4faac1da55d": "query GetLeaveRequests($childId: ID, $status: String) {\n leaveRequests(childId: $childId, status: $status) {\n id\n childName\n type\n startDate\n endDate\n reason\n status\n createdAt\n }\n}",
"9a6c2fbe99c9a3ce35b6b25ad48f8b898cd56b7b6b216548663d66e7f7aa9e0b": "query GetMyChildrenOverview {\n myChildren {\n id\n name\n grade\n className\n avatar\n recentGrades {\n subject\n score\n }\n attendance {\n present\n total\n }\n homeworkCompletion {\n completed\n total\n }\n }\n}",
"40615d4701dbadf4dcd22df3a0fde0a18ba52ab1cc096d3f6c6b0b4fb90247b8": "mutation RejectLeave($id: ID!, $reason: String) {\n rejectLeave(id: $id, reason: $reason) {\n id\n status\n }\n}",
"1d02d46cf5602608fa24a193e036982729c98034a1541267f8cea7eeba8e777a": "mutation CreateInvitationCode($input: CreateInvitationCodeInput!) {\n createInvitationCode(input: $input) {\n id\n code\n role\n maxUses\n expiresAt\n }\n}",
"b6eab2d2ddb72100fef604261587be516707b45c86d9ba6e6f4c6ec3707ed17a": "query GetAuditLogs($filter: AuditLogFilter, $limit: Int, $offset: Int) {\n auditLogs(filter: $filter, limit: $limit, offset: $offset) {\n items {\n id\n userId\n userName\n action\n resource\n resourceId\n ip\n timestamp\n details\n }\n total\n }\n}",
"9ec7b17074866cd384bc5c0d5f3b6d90ea1cbc3220c5533eff98be7fc8bd5506": "query GetInvitationCodes($status: String) {\n invitationCodes(status: $status) {\n id\n code\n role\n status\n usedCount\n maxUses\n expiresAt\n createdAt\n createdBy\n }\n}",
"462711c89347b1a40af04f60ab1dd3611c8361163740cf202454d5ec90c0bb71": "query GetLayoutTemplates {\n layoutTemplates {\n layoutId\n displayName\n description\n availableSlots\n }\n}",
"00c61040a7495895b84bf3a71e1368892e0757783a7977e101f7c60675538830": "query GetPermissions {\n permissions {\n id\n name\n resource\n action\n description\n }\n}",
"7d1683c3ae0b368ca79aa9f07cd34c37b6e7dc7b38d48741c19beb5c0e412136": "query GetPluginRegistry {\n pluginRegistry {\n pluginId\n category\n version\n displayName\n description\n requiredRoles\n isBuiltin\n isActive\n defaultSlot\n defaultSize\n defaultProps\n propsSchema\n }\n}",
"013b882847e6aeceebeef7b499e2fdb39f327d6fbd8d9b793f2ec17209d01046": "query GetRoles {\n roles {\n id\n name\n permissions {\n id\n name\n resource\n action\n }\n }\n}",
"8c92bccc65a523c79bf18c567f6396be0efdd6b0cfda4567702faa57798fa170": "query GetRoleLayoutDefault($role: String) {\n roleLayoutDefault(role: $role) {\n role\n layoutId\n slotOverrides\n }\n}",
"4c44f675ea0fe1ed6303dba87ba27c6feef0393649b0909a3436c38ae0edc331": "query GetRolePluginMapping($role: String) {\n rolePluginMapping(role: $role) {\n role\n pluginId\n slot\n sortOrder\n isEnabled\n widgetProps\n }\n}",
"7d372896206547c1fc4edcc2af151543922dcd3d403370c81d59ad784a352572": "query GetSchool {\n school {\n id\n name\n address\n phone\n email\n currentAcademicYear\n currentTerm\n semesterStart\n semesterEnd\n }\n}",
"86265727fbd9321020726a78323688432e8e1d3d05f6a01678602e2d96f581d0": "query GetUsers($role: String, $limit: Int, $offset: Int) {\n users(role: $role, limit: $limit, offset: $offset) {\n items {\n id\n name\n email\n role\n status\n createdAt\n }\n total\n }\n}",
"44c439b9e2b79a7be455bd2da2e71b76290f51958c22a44ff830a6ca330c06c2": "mutation ResetUserLayoutOverride($userId: ID!) {\n resetUserLayoutOverride(userId: $userId) {\n userId\n }\n}",
"42eb5f67bd419bcfb86717b4ce857ff9ce07e87ae7d1c6ceffea84529c4fa6f6": "mutation RevokeInvitationCode($id: ID!) {\n revokeInvitationCode(id: $id) {\n id\n status\n }\n}",
"bc0493cf664efd0e3301ff91f601da9e5ffca5b070d309c56f2b3798b9b02d07": "mutation UpdatePluginRegistry($pluginId: ID!, $input: PluginRegistryInput!) {\n updatePluginRegistry(pluginId: $pluginId, input: $input) {\n pluginId\n isActive\n defaultProps\n }\n}",
"2677bf6b6c5e064dd35062044d93948b30ddc3855b26444c463dafa911e5ecf8": "mutation UpdateRoleLayoutDefault($role: String!, $layoutId: String!) {\n updateRoleLayoutDefault(role: $role, layoutId: $layoutId) {\n role\n layoutId\n }\n}",
"a34209d2f6cb3a0bd30111c45d62cb0aba888cc000408062ee5d3a2eace0f2a4": "mutation UpdateRolePermissions($roleId: ID!, $permissionIds: [ID]!) {\n updateRolePermissions(roleId: $roleId, permissionIds: $permissionIds) {\n id\n }\n}",
"f52005687a6d34d6606e60be4f63fe773c846cb3018ee75990e6d0f29d3622e6": "mutation UpdateRoleMapping($role: String!, $mappings: [RolePluginMappingInput!]!) {\n updateRolePluginMapping(role: $role, mappings: $mappings) {\n role\n pluginId\n isEnabled\n }\n}",
"aeba6eea7c2723e5f2f522ec688f47bd269fd725f7131e849ab80efa384b4369": "mutation UpdateSchool($input: SchoolInput!) {\n updateSchool(input: $input) {\n id\n name\n address\n phone\n email\n currentAcademicYear\n currentTerm\n semesterStart\n semesterEnd\n updatedAt\n }\n}",
"257075c859c0c4dbf9f253e2440a98ea778b2a66e4934055dd64263cd5a99bad": "mutation UpdateUserRole($id: ID!, $role: String!) {\n updateUserRole(id: $id, role: $role) {\n id\n role\n }\n}",
"543d328988a535e73794a1c45a53f2f19d6494f4b0be675f90917d5d5578bc46": "mutation UpdateUserStatus($id: ID!, $status: String!) {\n updateUserStatus(id: $id, status: $status) {\n id\n status\n }\n}"
}

View File

@@ -0,0 +1,64 @@
// Codegen contract validation check (ARCHITECTURE.md §10 P1-8 / §5.3)
//
// Runs graphql-codegen and fails if any output with skipDocumentsValidation:false
// contains operations that reference non-existent schema fields.
// This is the "codegen diff check" — it diffs operations against schema.
//
// Currently enforces:
// - dashboard-types.ts output (skipDocumentsValidation: false, P1-7)
// As more domains fix their operations, their outputs will be validated too.
//
// Usage: tsx scripts/check-codegen.ts
// Exit: 0 = codegen success, 1 = validation errors
//
// Related: ARCHITECTURE.md §5.3 契约纪律, §10 P1-8
import { execSync } from "node:child_process";
function main(): void {
console.log("=== Codegen Contract Validation Check ===");
console.log("Running: pnpm run codegen (normalize-schema + graphql-codegen)");
console.log("");
try {
const output = execSync("pnpm run codegen", {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
console.log(output);
// Check for validation failures even on exit 0 (some may be warnings)
if (output.includes("GraphQL Document Validation failed")) {
console.log("❌ Codegen reported validation failures despite exit 0");
console.log("Result: FAIL");
process.exit(1);
}
console.log(
"Result: PASS (codegen succeeded, all validated outputs clean)",
);
process.exit(0);
} catch (err: unknown) {
const e = err as { stdout?: string; stderr?: string; message: string };
const output = `${e.stdout ?? ""}\n${e.stderr ?? ""}`;
console.log(output);
if (output.includes("GraphQL Document Validation failed")) {
console.log(
"❌ Codegen validation failed — operations reference non-existent schema fields",
);
console.log(
" Fix: update operations/*.graphql.ts to match combined-schema.graphql",
);
console.log(
" Or: keep skipDocumentsValidation: true for that output until schema is ready",
);
} else {
console.log(`❌ Codegen failed: ${e.message}`);
}
console.log("Result: FAIL");
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,160 @@
// Page count baseline check (ARCHITECTURE.md §10 P1-8 / §11.6)
//
// Asserts that the total page.tsx count never drops below the baseline.
// Prevents accidental route deletion. When adding new pages, update the
// baseline in BASELINE.total. Per-category minimums catch regressions
// in specific areas (dashboards, login, etc.).
//
// Usage: tsx scripts/check-page-count.ts
// Exit: 0 = pass, 1 = below baseline
//
// Related: ARCHITECTURE.md §10 P1-8, §11.6 验收纪律
import * as fs from "node:fs";
import * as path from "node:path";
const APP_DIR = path.resolve(process.cwd(), "src/app");
interface Baseline {
total: number;
categories: Record<string, { pattern: string; min: number; label: string }>;
}
// Baseline as of P2 (2026-07-22, grades module added). Update when adding pages.
const BASELINE: Baseline = {
total: 31,
categories: {
dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
min: 4,
label: "Role dashboards (admin/teacher/student/parent)",
},
login: {
pattern: "login/page.tsx",
min: 1,
label: "Login page",
},
root: {
pattern: "page.tsx",
min: 1,
label: "Root redirect page",
},
forbidden: {
pattern: "shell/forbidden/page.tsx",
min: 1,
label: "Forbidden page",
},
catchAll: {
pattern: "shell/[[...route]]/page.tsx",
min: 1,
label: "Shell catch-all",
},
devTemplates: {
pattern: "shell/dev/templates/**/page.tsx",
min: 5,
label: "Dev template pages",
},
},
};
function scanPages(): string[] {
const pages: string[] = [];
function walk(dir: string, base: string): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(base, full).replace(/\\/g, "/");
if (entry.isDirectory()) {
walk(full, base);
} else if (entry.name === "page.tsx") {
pages.push(rel);
}
}
}
walk(APP_DIR, APP_DIR);
return pages.sort();
}
function matchGlob(pattern: string, relPath: string): boolean {
// Glob → regex: ** (any path), * (within segment), {a,b} (alternation)
let result = "";
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === "*" && pattern[i + 1] === "*") {
// ** — match anything including /; skip trailing /
result += ".*";
i += 2;
if (pattern[i] === "/") i++;
} else if (c === "*") {
result += "[^/]*";
i++;
} else if (c === "{") {
const end = pattern.indexOf("}", i);
if (end === -1) {
result += "\\{";
i++;
} else {
const opts = pattern
.slice(i + 1, end)
.split(",")
.map((s) => s.trim());
result += `(${opts.join("|")})`;
i = end + 1;
}
} else if (".+?^$()[]|\\".includes(c)) {
result += `\\${c}`;
i++;
} else {
result += c;
i++;
}
}
return new RegExp(`^${result}$`).test(relPath);
}
function main(): void {
const pages = scanPages();
const total = pages.length;
console.log("=== Page Count Baseline Check ===");
console.log(`Total page.tsx files: ${total} (baseline: ${BASELINE.total})`);
console.log("");
// Per-category check
let categoryFail = false;
for (const [, cat] of Object.entries(BASELINE.categories)) {
const matched = pages.filter((p) => matchGlob(cat.pattern, p));
const count = matched.length;
const status = count >= cat.min ? "✅" : "❌";
if (count < cat.min) categoryFail = true;
console.log(` ${status} ${cat.label}: ${count} (min ${cat.min})`);
}
console.log("");
// Total check
const totalOk = total >= BASELINE.total;
if (!totalOk) {
console.log(`❌ Total ${total} < baseline ${BASELINE.total}`);
}
// List all pages
console.log("Pages:");
for (const p of pages) {
console.log(
` /${p.replace(/\/page\.tsx$/, "").replace(/^page\.tsx$/, "")}`,
);
}
console.log("");
if (!totalOk || categoryFail) {
console.log("Result: FAIL");
process.exit(1);
} else {
console.log(`Result: PASS (${total} pages, all categories meet minimum)`);
process.exit(0);
}
}
main();

View File

@@ -0,0 +1,169 @@
// Route table consistency check (ARCHITECTURE.md §10 P1-8)
//
// Verifies that every actual /shell/* page.tsx route is registered in
// route-permissions.ts (EXACT / PREFIX / DASHBOARD / PUBLIC_ROUTES).
// Catches "unregistered routes" that would fall through to the catch-all
// and be denied by middleware (fail-closed) — developers get a clear CI
// error instead of a confusing runtime 403.
//
// Also reports "ghost entries" (EXACT table entries without a page.tsx)
// as informational output — these are planned future routes (P2-P5).
//
// Usage: tsx scripts/check-route-table.ts
// Exit: 0 = pass, 1 = violations found
//
// Related: ARCHITECTURE.md §3.4 V3-A1, §5.3, §10 P1-8, §11.7 红线 #5
import * as fs from "node:fs";
import * as path from "node:path";
const APP_DIR = path.resolve(process.cwd(), "src/app");
const ROUTE_PERMS_FILE = path.resolve(
process.cwd(),
"src/shared/lib/route-permissions.ts",
);
interface Violation {
type: "unregistered_route" | "ghost_entry";
route: string;
detail: string;
}
function scanActualRoutes(): Set<string> {
const routes = new Set<string>();
function walk(dir: string, prefix: string): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip catch-all [[...route]] directory
if (entry.name.startsWith("[[")) continue;
walk(full, `${prefix}/${entry.name}`);
} else if (entry.name === "page.tsx") {
routes.add(prefix || "/");
}
}
}
walk(APP_DIR, "");
return routes;
}
function extractRegisteredRoutes(): {
exact: Set<string>;
prefixes: string[];
publicRoutes: Set<string>;
} {
const content = fs.readFileSync(ROUTE_PERMS_FILE, "utf8");
const exact = new Set<string>();
const prefixes: string[] = [];
const publicRoutes = new Set<string>();
const routeKeyRe = new RegExp('"(/[^"]*?)":\\s*\\{', "g");
const prefixRe = new RegExp('prefix:\\s*"(/[^"]*?)"', "g");
let m: RegExpExecArray | null;
while ((m = routeKeyRe.exec(content)) !== null) {
exact.add(m[1]);
}
while ((m = prefixRe.exec(content)) !== null) {
prefixes.push(m[1]);
}
// PUBLIC_ROUTES array entries (skip past `readonly string[] =` to the real `[`)
const publicBlock = content.match(
new RegExp("PUBLIC_ROUTES[^=]*=\\s*\\[([\\s\\S]*?)\\]"),
)?.[1];
if (publicBlock) {
const re = new RegExp('"(/[^"]*?)"', "g");
while ((m = re.exec(publicBlock)) !== null) {
publicRoutes.add(m[1]);
}
}
return { exact, prefixes, publicRoutes };
}
function isRegistered(
route: string,
exact: Set<string>,
prefixes: string[],
publicRoutes: Set<string>,
): boolean {
if (publicRoutes.has(route)) return true;
if (exact.has(route)) return true;
for (const p of prefixes) {
if (route.startsWith(p)) return true;
}
return false;
}
function main(): void {
const actualRoutes = scanActualRoutes();
const { exact, prefixes, publicRoutes } = extractRegisteredRoutes();
const violations: Violation[] = [];
// Check A: every actual /shell/* route must be registered
for (const route of [...actualRoutes].sort()) {
if (!route.startsWith("/shell")) continue;
if (!isRegistered(route, exact, prefixes, publicRoutes)) {
violations.push({
type: "unregistered_route",
route,
detail:
"page.tsx exists but route not in EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES",
});
}
}
// Check B (informational): ghost entries (EXACT entries without page.tsx)
const ghostEntries: string[] = [];
for (const entry of [...exact].sort()) {
if (!entry.startsWith("/shell/")) continue;
if (!actualRoutes.has(entry)) {
ghostEntries.push(entry);
}
}
// Report
console.log("=== Route Table Consistency Check ===");
console.log(
`Actual /shell/* routes: ${[...actualRoutes].filter((r) => r.startsWith("/shell")).length}`,
);
console.log(
`EXACT entries: ${[...exact].filter((r) => r.startsWith("/shell/")).length}`,
);
console.log(`PREFIX entries: ${prefixes.length}`);
console.log(`PUBLIC_ROUTES: ${publicRoutes.size}`);
console.log("");
if (violations.length > 0) {
console.log("❌ VIOLATIONS (unregistered routes):");
for (const v of violations) {
console.log(` ${v.route}${v.detail}`);
}
console.log("");
}
if (ghostEntries.length > 0) {
console.log(
` GHOST ENTRIES (planned, no page.tsx yet): ${ghostEntries.length}`,
);
for (const g of ghostEntries) {
console.log(` ${g}`);
}
console.log("");
}
if (violations.length > 0) {
console.log(`Result: FAIL (${violations.length} violation(s))`);
process.exit(1);
} else {
console.log(
`Result: PASS (0 violations, ${ghostEntries.length} ghost entries)`,
);
process.exit(0);
}
}
main();

View File

@@ -0,0 +1,78 @@
/**
* Persisted Query Manifest 生成脚本v2.1 M3 安全加固)
*
* 构建时遍历 src/lib/api/operations/ 中所有 gql 文档,生成
* sha256(query) → query 文本 的白名单 manifest。
*
* 部署到 apollo-router 容器生产模式APOLLO_REQUIRE_PQ_MANIFEST=true
* 拒绝 manifest 之外的查询,防止攻击者构造任意 query 探测 schema。
*
* 产物apps/portal-shell/public/pq-manifest.json
* 关联portal-shell spec §4.2 PQ Manifest
*/
import { print } from "graphql";
import { sha256 } from "crypto-hash";
import * as fs from "node:fs";
import * as path from "node:path";
import * as url from "node:url";
// 使用 fileURLToPath 兼容 ESM 下 __dirname 缺失
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 动态 import operations barrel含 51 个 gql DocumentNode
// Windows 下动态 import 需 file:// URLESM 限制)
const operationsPath = path.resolve(
__dirname,
"../src/lib/api/operations/index.ts",
);
const operationsUrl = url.pathToFileURL(operationsPath).href;
async function generateManifest(): Promise<void> {
// tsx 运行时支持直接 import .ts
const operationsModule = (await import(operationsUrl)) as Record<
string,
unknown
>;
const manifest: Record<string, string> = {};
let skipped = 0;
for (const [, doc] of Object.entries(operationsModule)) {
// 仅处理 DocumentNode 对象(含 loc.source.body
if (
typeof doc === "object" &&
doc !== null &&
"loc" in doc &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(doc as any).loc !== null &&
typeof (doc as { loc: { source: { body: string } } }).loc.source?.body ===
"string"
) {
const query = print(doc as never);
const hash = await sha256(query);
manifest[hash] = query;
} else {
skipped++;
}
}
const outDir = path.resolve(__dirname, "../public");
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const outPath = path.resolve(outDir, "pq-manifest.json");
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2));
const count = Object.keys(manifest).length;
console.log(
`✓ PQ manifest generated: ${count} queries` +
(skipped > 0 ? ` (${skipped} non-document exports skipped)` : ""),
);
console.log(` Output: ${outPath}`);
}
generateManifest().catch((err) => {
console.error("✗ Failed to generate PQ manifest:", err);
process.exit(1);
});

View File

@@ -0,0 +1,197 @@
// Normalize federation subgraph schemas for graphql-codegen.
//
// Problem: services schema.graphql files are federation subgraphs that use
// `extend type Query` (with no body) and multiple `extend type Query { ... }`
// blocks. Standard GraphQL parser used by codegen cannot parse these.
//
// Solution: read all 7 subgraph schema files, merge all `extend type Query`
// blocks into a single `type Query { ... }`, output to a combined SDL file.
//
// Usage: tsx scripts/normalize-schema.ts
// Output: src/lib/api/__generated__/combined-schema.graphql
//
// Related: spec section 2.4
import * as fs from "node:fs";
import * as path from "node:path";
interface SubgraphSchema {
name: string;
content: string;
}
const SCHEMA_FILES = [
{
name: "iam",
path: "../../services/iam/src/graphql/generated/schema.graphql",
},
{
name: "config-service",
path: "../../services/config-service/src/graphql/generated/schema.graphql",
},
{
name: "core-edu",
path: "../../services/core-edu/src/graphql/generated/schema.graphql",
},
{
name: "content",
path: "../../services/content/src/graphql/generated/schema.graphql",
},
{
name: "msg",
path: "../../services/msg/src/graphql/generated/schema.graphql",
},
{
name: "data-ana",
path: "../../services/data-ana/src/graphql/generated/schema.graphql",
},
{
name: "ai",
path: "../../services/ai/src/graphql/generated/schema.graphql",
},
];
function loadSchemas(): SubgraphSchema[] {
const baseDir = process.cwd();
return SCHEMA_FILES.map((f) => ({
name: f.name,
content: fs.readFileSync(path.resolve(baseDir, f.path), "utf8"),
}));
}
// Strip federation `extend type Query` syntax:
// - Remove standalone `extend type Query` (no body)
// - Convert `extend type Query { ... }` blocks to `type Query { ... }`
// (we collect fields and merge later)
function normalizeSchema(content: string): {
staticDefs: string;
queryFields: string[];
} {
const lines = content.split("\n");
const staticDefs: string[] = [];
const queryFields: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Match `extend type Query` (no body, single line)
if (/^\s*extend\s+type\s+Query\s*$/.test(line)) {
i++;
continue;
}
// Match `extend type Query {` block start
const blockStart = line.match(/^\s*extend\s+type\s+Query\s*\{/);
if (blockStart) {
// Collect fields until matching `}`
i++;
while (i < lines.length && !/^\s*\}\s*$/.test(lines[i])) {
const fieldLine = lines[i];
if (fieldLine.trim()) {
queryFields.push(fieldLine);
}
i++;
}
// Skip closing `}`
i++;
continue;
}
// Match `type Query {` block (already standard, but merge fields)
const stdBlockStart = line.match(/^\s*type\s+Query\s*\{/);
if (stdBlockStart) {
i++;
while (i < lines.length && !/^\s*\}\s*$/.test(lines[i])) {
const fieldLine = lines[i];
if (fieldLine.trim()) {
queryFields.push(fieldLine);
}
i++;
}
i++;
continue;
}
staticDefs.push(line);
i++;
}
return { staticDefs: staticDefs.join("\n"), queryFields };
}
// Sanitize invalid input field types.
//
// Problem: services/ai subgraph declares `input ChatRequestInput { messages:
// ChatMessage }` and `input ChatResponseInput { usage: Usage }` where
// ChatMessage/Usage are OUTPUT types. GraphQL spec forbids input fields
// referencing output types; graphql-codegen's typescript plugin rejects this.
//
// Solution: rewrite those offending input field types to `String` in the
// combined schema. This is a codegen-only sanitize; the runtime apollo-router
// uses the original subgraph schemas directly.
//
// Related: spec section 2.4
const SANITIZE_INPUT_FIELD_REPLACEMENTS: Array<{
inputName: string;
fieldName: string;
replacement: string;
}> = [
// services/ai: input ChatRequestInput { messages: ChatMessage }
{
inputName: "ChatRequestInput",
fieldName: "messages",
replacement: "String",
},
// services/ai: input ChatResponseInput { usage: Usage }
{ inputName: "ChatResponseInput", fieldName: "usage", replacement: "String" },
];
function sanitizeInputFields(content: string): string {
let out = content;
for (const r of SANITIZE_INPUT_FIELD_REPLACEMENTS) {
// Match ` fieldName: OriginalType` lines within `input InputName { ... }`
// blocks. We rely on the simple field-line format generated above.
const inputBlockRe = new RegExp(
`(input\\s+${r.inputName}\\s*\\{[^}]*?)` +
`(\\s{2,}${r.fieldName}\\s*:\\s*)[A-Za-z_][A-Za-z0-9_\\[\\]!]*`,
"g",
);
out = out.replace(inputBlockRe, `$1$2${r.replacement}`);
}
return out;
}
function main(): void {
const schemas = loadSchemas();
const allStaticDefs: string[] = [];
const allQueryFields: string[] = [];
for (const s of schemas) {
const { staticDefs, queryFields } = normalizeSchema(s.content);
allStaticDefs.push(`# === ${s.name} subgraph ===`);
allStaticDefs.push(staticDefs.trim());
allQueryFields.push(...queryFields);
}
// Deduplicate query fields (in case multiple subgraphs define same field)
const uniqueQueryFields = Array.from(
new Set(allQueryFields.map((f) => f.trim())),
);
let combined = [
"# Combined normalized schema for graphql-codegen (federation stripped)",
"# DO NOT EDIT - generated by scripts/normalize-schema.ts",
"",
...allStaticDefs,
"",
"type Query {",
...uniqueQueryFields.map((f) => ` ${f}`),
"}",
"",
].join("\n");
combined = sanitizeInputFields(combined);
const outDir = path.resolve(process.cwd(), "src/lib/api/__generated__");
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, "combined-schema.graphql");
fs.writeFileSync(outPath, combined);
console.log(`Combined schema written to ${outPath}`);
console.log(` Query fields: ${uniqueQueryFields.length}`);
}
main();

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactNode } from "react";
/**
* E2E 集成测试:三级错误边界
*
* 模拟 portal-shell 三级错误边界层级:
* 1. Route 级error.tsx→ 捕获整个路由的渲染错误
* 2. Section 级DashboardSection→ 捕获单个区块的错误
* 3. Widget 级PluginBoundary→ 捕获单个插件的错误
*
* 验证:低级错误不冒泡到高级边界,高级边界兜底未捕获的低级错误
*
* 关联portal-shell README v2.0 §5.4 三级错误处理
*/
// mock useErrorReport
const reportErrorMock = vi.fn();
vi.mock("@edu/hooks", () => ({
useErrorReport: () => reportErrorMock,
}));
import { ErrorBoundary } from "@edu/ui-components";
import { PluginBoundary } from "@/shared/components/plugin-boundary";
/** 制造抛错组件 */
function ThrowOnRender({ message }: { message: string }): ReactNode {
throw new Error(message);
}
function GoodComponent({ label }: { label: string }): ReactNode {
return <div data-testid={`good-${label}`}>{label}</div>;
}
describe("E2E: 三级错误边界", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("L3 Widget 级错误不冒泡到 L2 Section 级", () => {
render(
<ErrorBoundary
fallback={<div data-testid="section-error">Section </div>}
>
<div data-testid="section">
<PluginBoundary pluginId="bad-widget">
<ThrowOnRender message="Widget 崩溃" />
</PluginBoundary>
<GoodComponent label="sibling" />
</div>
</ErrorBoundary>,
);
// Widget 级 fallback 显示
expect(screen.getByText("插件加载失败")).toBeTruthy();
// Section 级 fallback 不显示
expect(screen.queryByTestId("section-error")).toBeNull();
// 兄弟组件正常渲染
expect(screen.getByTestId("good-sibling")).toBeTruthy();
});
it("L3 Widget 级错误被上报到 /api/log", () => {
render(
<PluginBoundary pluginId="reported-widget">
<ThrowOnRender message="需上报的 Widget 错误" />
</PluginBoundary>,
);
expect(reportErrorMock).toHaveBeenCalledTimes(1);
const [error, options] = reportErrorMock.mock.calls[0]!;
expect((error as Error).message).toBe("需上报的 Widget 错误");
expect(options).toEqual({
pluginId: "reported-widget",
level: "error",
});
});
it("L2 Section 级错误不冒泡到 L1 Route 级", () => {
render(
<ErrorBoundary fallback={<div data-testid="route-error">Route </div>}>
<div data-testid="route">
<ErrorBoundary
fallback={<div data-testid="section-error">Section </div>}
>
<ThrowOnRender message="Section 崩溃" />
</ErrorBoundary>
<GoodComponent label="route-sibling" />
</div>
</ErrorBoundary>,
);
// Section 级 fallback 显示
expect(screen.getByTestId("section-error")).toBeTruthy();
// Route 级 fallback 不显示
expect(screen.queryByTestId("route-error")).toBeNull();
// Route 级兄弟组件正常渲染
expect(screen.getByTestId("good-route-sibling")).toBeTruthy();
});
it("未捕获的 L1 Route 级错误由 Route ErrorBoundary 兜底", () => {
render(
<ErrorBoundary fallback={<div data-testid="route-error">Route </div>}>
<ThrowOnRender message="未捕获的顶层错误" />
</ErrorBoundary>,
);
expect(screen.getByTestId("route-error")).toBeTruthy();
});
it("Widget 重试后恢复正常", () => {
let shouldThrow = true;
function FlakyWidget(): ReactNode {
if (shouldThrow) throw new Error("偶发错误");
return <div data-testid="recovered"></div>;
}
render(
<PluginBoundary pluginId="flaky">
<FlakyWidget />
</PluginBoundary>,
);
expect(screen.getByText("插件加载失败")).toBeTruthy();
shouldThrow = false;
fireEvent.click(screen.getByText("重试"));
expect(screen.getByTestId("recovered")).toBeTruthy();
});
it("多个 Widget 同时出错互不影响", () => {
render(
<div>
<PluginBoundary pluginId="widget-1">
<ThrowOnRender message="Widget 1 崩溃" />
</PluginBoundary>
<PluginBoundary pluginId="widget-2">
<ThrowOnRender message="Widget 2 崩溃" />
</PluginBoundary>
<PluginBoundary pluginId="widget-3">
<GoodComponent label="widget-3" />
</PluginBoundary>
</div>,
);
// 两个崩溃的 Widget 都显示 fallback
expect(screen.getAllByText("插件加载失败").length).toBe(2);
// 正常的 Widget 不受影响
expect(screen.getByTestId("good-widget-3")).toBeTruthy();
});
});

View File

@@ -0,0 +1,379 @@
import { describe, it, expect } from "vitest";
import {
checkRoutePermission,
batchCheckRoutePermission,
} from "@/shared/lib/route-permissions";
import {
decodePermissionsBitmap,
encodePermissionsBitmap,
} from "@edu/shared-ts/permission-bitmap";
/**
* E2E 集成测试:三层安全边界
*
* 模拟用户访问不同路由时的权限校验全流程:
* 1. L1 角色门禁4 角色admin/teacher/student/parent路由隔离
* 2. L2 权限点门禁67 权限点位图校验AND/OR 语义)
* 3. L3 数据范围:运行时校验(此处模拟路由级检查)
*
* 验证:跨角色访问被拒、跨权限访问被拒、合法访问放行
*
* 关联portal-shell README v2.0 §3.3 三层安全边界
*/
// ── 测试用户 ──────────────────────────────────────────────
const ADMIN_USER = {
role: "admin" as const,
bitmap: encodePermissionsBitmap([
"DASHBOARD_ADMIN_READ",
"DASHBOARD_READ",
"USER_MANAGE",
"ROLE_MANAGE",
"PERMISSION_MANAGE",
"AUDIT_LOG_READ",
"SCHOOL_MANAGE",
"PLUGIN_REGISTRY_MANAGE",
"INVITATION_CODE_CREATE",
"ANNOUNCEMENT_MANAGE",
"CLASS_MANAGE",
]),
};
const TEACHER_USER = {
role: "teacher" as const,
bitmap: encodePermissionsBitmap([
"DASHBOARD_TEACHER_READ",
"DASHBOARD_READ",
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"QUESTION_READ",
"TEXTBOOK_READ",
"EXAM_READ",
"HOMEWORK_READ",
"GRADE_RECORD_MANAGE",
"ATTENDANCE_READ",
]),
};
const STUDENT_USER = {
role: "student" as const,
bitmap: encodePermissionsBitmap([
"DASHBOARD_STUDENT_READ",
"DASHBOARD_READ",
"ERROR_BOOK_READ",
"LEARNING_PATH_READ",
"AI_TUTOR_USE",
"ELECTIVE_SELECT",
]),
};
const PARENT_USER = {
role: "parent" as const,
bitmap: encodePermissionsBitmap([
"DASHBOARD_PARENT_READ",
"DASHBOARD_READ",
"GRADE_READ_CHILD",
"LEAVE_APPROVAL_MANAGE",
]),
};
describe("E2E: 三层安全边界", () => {
describe("L1 角色门禁", () => {
it("admin 访问 admin 仪表盘 → 放行", () => {
const result = checkRoutePermission(
"/shell/admin",
ADMIN_USER.bitmap,
ADMIN_USER.role,
);
expect(result.allowed).toBe(true);
});
it("teacher 访问 admin 仪表盘 → 拒绝missing_role", () => {
const result = checkRoutePermission(
"/shell/admin",
TEACHER_USER.bitmap,
TEACHER_USER.role,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_role");
});
it("student 访问 teacher 仪表盘 → 拒绝missing_role", () => {
const result = checkRoutePermission(
"/shell/teacher",
STUDENT_USER.bitmap,
STUDENT_USER.role,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_role");
});
it("parent 访问 student 仪表盘 → 拒绝missing_role", () => {
const result = checkRoutePermission(
"/shell/student",
PARENT_USER.bitmap,
PARENT_USER.role,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_role");
});
it("4 角色各自访问自己的仪表盘 → 全部放行", () => {
expect(
checkRoutePermission("/shell/admin", ADMIN_USER.bitmap, ADMIN_USER.role)
.allowed,
).toBe(true);
expect(
checkRoutePermission(
"/shell/teacher",
TEACHER_USER.bitmap,
TEACHER_USER.role,
).allowed,
).toBe(true);
expect(
checkRoutePermission(
"/shell/student",
STUDENT_USER.bitmap,
STUDENT_USER.role,
).allowed,
).toBe(true);
expect(
checkRoutePermission(
"/shell/parent",
PARENT_USER.bitmap,
PARENT_USER.role,
).allowed,
).toBe(true);
});
});
describe("L2 权限点门禁AND 语义)", () => {
it("admin 有 USER_MANAGE → 访问用户管理放行", () => {
const result = checkRoutePermission(
"/shell/admin/users",
ADMIN_USER.bitmap,
ADMIN_USER.role,
);
expect(result.allowed).toBe(true);
});
it("admin 缺少 USER_MANAGE → 访问用户管理拒绝missing_permission", () => {
const noUserManage = encodePermissionsBitmap([
"DASHBOARD_ADMIN_READ",
"DASHBOARD_READ",
]);
const result = checkRoutePermission(
"/shell/admin/users",
noUserManage,
"admin",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_permission");
expect(result.missingPermissions).toEqual(["USER_MANAGE"]);
});
it("student 访问 AI 辅导需要 AI_TUTOR_USE 权限", () => {
const result = checkRoutePermission(
"/shell/student/ai-tutor",
STUDENT_USER.bitmap,
STUDENT_USER.role,
);
expect(result.allowed).toBe(true);
const noAi = encodePermissionsBitmap([
"DASHBOARD_STUDENT_READ",
"DASHBOARD_READ",
]);
const denied = checkRoutePermission(
"/shell/student/ai-tutor",
noAi,
"student",
);
expect(denied.allowed).toBe(false);
expect(denied.reason).toBe("missing_permission");
});
});
describe("L2 权限点门禁OR 语义 - anyOfPermissions", () => {
it("teacher 有 LESSON_PLAN_READ → 访问备课管理放行", () => {
const result = checkRoutePermission(
"/shell/teacher/lesson-plans",
TEACHER_USER.bitmap,
TEACHER_USER.role,
);
expect(result.allowed).toBe(true);
});
it("teacher 有 QUESTION_READ → 访问题库放行OR 语义)", () => {
const result = checkRoutePermission(
"/shell/teacher/question-bank",
TEACHER_USER.bitmap,
TEACHER_USER.role,
);
expect(result.allowed).toBe(true);
});
it("teacher 缺少所有备课权限 → 拒绝", () => {
const noLessonPlan = encodePermissionsBitmap([
"DASHBOARD_TEACHER_READ",
"DASHBOARD_READ",
"QUESTION_READ",
]);
const result = checkRoutePermission(
"/shell/teacher/lesson-plans",
noLessonPlan,
"teacher",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_permission");
});
});
describe("L3 数据范围(模拟)", () => {
it("parent 有 GRADE_READ_CHILD → 访问子女管理放行", () => {
const result = checkRoutePermission(
"/shell/parent/children",
PARENT_USER.bitmap,
PARENT_USER.role,
);
expect(result.allowed).toBe(true);
});
it("parent 有 LEAVE_APPROVAL_MANAGE → 访问请假审批放行", () => {
const result = checkRoutePermission(
"/shell/parent/leave-approval",
PARENT_USER.bitmap,
PARENT_USER.role,
);
expect(result.allowed).toBe(true);
});
it("student 有 ELECTIVE_SELECT → 访问选修课选择放行OR 语义)", () => {
const result = checkRoutePermission(
"/shell/student/electives",
STUDENT_USER.bitmap,
STUDENT_USER.role,
);
expect(result.allowed).toBe(true);
});
it("student 缺少 ELECTIVE_SELECT 和 ELECTIVE_READ → 拒绝", () => {
const noElective = encodePermissionsBitmap([
"DASHBOARD_STUDENT_READ",
"DASHBOARD_READ",
]);
const result = checkRoutePermission(
"/shell/student/electives",
noElective,
"student",
);
expect(result.allowed).toBe(false);
});
});
describe("跨角色越权访问全量测试", () => {
it("admin 访问所有 admin 路由 → 全部放行", () => {
const adminRoutes = [
"/shell/admin",
"/shell/admin/users",
"/shell/admin/roles",
"/shell/admin/permissions",
"/shell/admin/audit-logs",
"/shell/admin/school",
"/shell/admin/plugins",
"/shell/admin/invitation-codes",
];
const results = batchCheckRoutePermission(
adminRoutes,
ADMIN_USER.bitmap,
ADMIN_USER.role,
);
for (const route of adminRoutes) {
expect(results[route]).toBe(true);
}
});
it("teacher 访问所有 admin 路由 → 全部拒绝", () => {
const adminRoutes = [
"/shell/admin/users",
"/shell/admin/roles",
"/shell/admin/audit-logs",
];
const results = batchCheckRoutePermission(
adminRoutes,
TEACHER_USER.bitmap,
TEACHER_USER.role,
);
for (const route of adminRoutes) {
expect(results[route]).toBe(false);
}
});
it("student 访问 teacher 路由 → 全部拒绝", () => {
const teacherRoutes = [
"/shell/teacher",
"/shell/teacher/lesson-plans",
"/shell/teacher/question-bank",
];
const results = batchCheckRoutePermission(
teacherRoutes,
STUDENT_USER.bitmap,
STUDENT_USER.role,
);
for (const route of teacherRoutes) {
expect(results[route]).toBe(false);
}
});
it("公共路由所有角色均可访问", () => {
const publicRoutes = [
"/",
"/login",
"/shell/forbidden",
"/api/log",
"/api/healthz",
];
for (const user of [
ADMIN_USER,
TEACHER_USER,
STUDENT_USER,
PARENT_USER,
]) {
const results = batchCheckRoutePermission(
publicRoutes,
user.bitmap,
user.role,
);
for (const route of publicRoutes) {
expect(results[route]).toBe(true);
}
}
});
});
describe("权限提升模拟", () => {
it("admin 添加新权限后可访问新路由", () => {
// 初始 admin 没有 EXAM_GRADE
const initialResult = checkRoutePermission(
"/shell/teacher/exams/1",
ADMIN_USER.bitmap,
"admin",
);
// admin 角色匹配,但 anyOfPermissions 需要 EXAM_READ/EXAM_CREATE/EXAM_UPDATE/EXAM_GRADE
// ADMIN_USER 没有 EXAM_READ 等 → 拒绝
expect(initialResult.allowed).toBe(false);
// 添加 EXAM_READ 权限后
const withExam = encodePermissionsBitmap([
...decodePermissionsBitmap(ADMIN_USER.bitmap),
"EXAM_READ",
]);
const afterResult = checkRoutePermission(
"/shell/teacher/exams/1",
withExam,
"admin",
);
expect(afterResult.allowed).toBe(true);
});
});
});

View File

@@ -0,0 +1,170 @@
import { describe, it, expect } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import {
Component,
Suspense,
type ErrorInfo,
type ReactNode,
use,
} from "react";
/**
* E2E 集成测试流式渲染React 19 use() + Suspense
*
* 测试策略:
* - jsdom 环境下 React 19 use(promise) 在 promise 从 pending→resolved 切换时
* 依赖 React 内部调度器重新渲染,在测试环境中无法可靠触发。
* - 因此采用「预解析 Promise」模式promise 在 render 前已 resolved
* React 首次渲染时 use() 直接返回值Suspense 不触发 fallback
* - 对「pending → resolved」切换的验证改用多层 Suspense + 异步渲染断言。
*
* 关联portal-shell README v2.0 §4 流式渲染
*/
/** 模拟使用 use() 消费 Promise 的组件 */
function AsyncContent<T>({
promise,
render,
}: {
promise: Promise<T>;
render: (data: T) => ReactNode;
}): ReactNode {
const data = use(promise);
return <>{render(data)}</>;
}
/** 简化版 ErrorBoundary用于测试 Promise reject 由 ErrorBoundary 捕获) */
class TestErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; fallback: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
override componentDidCatch(_error: Error, _info: ErrorInfo): void {
// 测试中无需上报
}
override render(): ReactNode {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
/** 创建一个可控的 pending Promise永不自动 resolve */
function createPendingPromise<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolveFn!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
resolveFn = resolve;
});
return { promise, resolve: resolveFn };
}
describe("E2E: 流式渲染", () => {
it("Suspense 边界在 Promise pending 时显示骨架屏", () => {
const { promise } = createPendingPromise<string>();
render(
<Suspense fallback={<div data-testid="skeleton">...</div>}>
<AsyncContent
promise={promise}
render={(data) => <div data-testid="content">{data}</div>}
/>
</Suspense>,
);
expect(screen.getByTestId("skeleton")).toBeTruthy();
expect(screen.queryByTestId("content")).toBeNull();
});
it("已解析的 Promise 渲染实际内容(不触发 Suspense fallback", async () => {
// 预解析的 PromiseReact 首次渲染时 use() 直接返回值
const resolvedPromise = Promise.resolve("实际数据");
await act(async () => {
render(
<Suspense fallback={<div data-testid="skeleton">...</div>}>
<AsyncContent
promise={resolvedPromise}
render={(data) => <div data-testid="content">{data}</div>}
/>
</Suspense>,
);
// 等待微任务队列清空,让 React 处理已解析的 Promise
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByTestId("content")).toBeTruthy();
});
expect(screen.getByTestId("content").textContent).toBe("实际数据");
expect(screen.queryByTestId("skeleton")).toBeNull();
});
it("多层 Suspense 边界各自独立解析(外层预解析、内层 pending", async () => {
const outerPromise = Promise.resolve("外层");
const inner = createPendingPromise<string>();
await act(async () => {
render(
<Suspense fallback={<div data-testid="outer-skeleton"></div>}>
<AsyncContent
promise={outerPromise}
render={() => (
<div data-testid="outer-content">
<Suspense
fallback={<div data-testid="inner-skeleton"></div>}
>
<AsyncContent
promise={inner.promise}
render={() => (
<div data-testid="inner-content"></div>
)}
/>
</Suspense>
</div>
)}
/>
</Suspense>,
);
await Promise.resolve();
});
// 外层已解析 → 外层内容显示,内层仍 pending → 内层骨架显示
await waitFor(() => {
expect(screen.getByTestId("outer-content")).toBeTruthy();
});
expect(screen.getByTestId("inner-skeleton")).toBeTruthy();
expect(screen.queryByTestId("inner-content")).toBeNull();
});
it("已 reject 的 Promise 由 ErrorBoundary 捕获(而非 Suspense", async () => {
// 预 reject 的 PromiseReact 渲染时 use() 抛出错误
const rejectedPromise = Promise.reject(new Error("数据加载失败"));
await act(async () => {
render(
<TestErrorBoundary fallback={<div data-testid="error"></div>}>
<Suspense fallback={<div data-testid="skeleton">...</div>}>
<AsyncContent
promise={rejectedPromise}
render={() => <div data-testid="content"></div>}
/>
</Suspense>
</TestErrorBoundary>,
);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByTestId("error")).toBeTruthy();
});
expect(screen.queryByTestId("content")).toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
/**
* vitest 全局 setupportal-shell v2.0 P4 E2E 测试)
*
* 1. 显式启用 React act 环境React 19 + @testing-library/react 需要)
* 2. 注册 @testing-library/jest-dom matcherstoBeInTheDocument 等)
*/
import "@testing-library/jest-dom/vitest";
// React 19 act 环境标记:确保所有 React 状态更新都在 act() 内执行
// 缺失此标记会导致 Suspense/use() 在测试中不触发重新渲染
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

View File

@@ -0,0 +1,239 @@
/**
* 登录代理 Route HandlerP0-1ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.3
*
* 流程:
* Browser POST /api/auth/login { email, password }
* → 本 Route Handler 调 api-gateway /api/v1/iam/login
* → 成功后把 accessToken 写入 httpOnly cookie `edu_session`
* → 把 permissions 位图写入非 httpOnly cookie `edu_perms`(按钮级 UX 用,非安全依据)
* → 返回 { user } 给前端(不返回 tokenJS 永不接触 token
*
* 安全§4.3
* - cookie 名 `edu_session`HttpOnly + Secure(生产) + SameSite=Strict + Path=/
* - Max-Age 与 iam 返回的 expiresIn 对齐
* - 失败归一化错误401 / 429 / 5xx 分别处理
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.3、§6.1、§11.7 红线 #2
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import {
encodePermissionsBitmap,
PERMISSION_BITMAP_ORDER,
} from "@edu/shared-ts/permission-bitmap";
import type { Role } from "@edu/shared-ts/contracts";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const PERMS_COOKIE = "edu_perms";
const GATEWAY_URL =
process.env.API_GATEWAY_URL ||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
"http://localhost:8080";
const IAM_LOGIN_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/login`;
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
interface UserInfo {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
status: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface IamLoginResponse {
success: true;
data: { user: UserInfo; tokens: TokenPair };
}
/**
* 解析角色字符串为 portal-shell 4 角色之一。
* iam 返回 roles[],取主角色。
*/
function pickPrimaryRole(roles: string[]): Role {
for (const r of roles) {
if (r === "admin" || r === "teacher" || r === "student" || r === "parent") {
return r;
}
}
return "teacher";
}
/**
* 过滤出 PERMISSION_BITMAP_ORDER 中存在的权限点(避免位图编码丢失)。
*/
function filterKnownPermissions(perms: string[]): string[] {
const known = new Set<string>(PERMISSION_BITMAP_ORDER);
return perms.filter((p) => known.has(p));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
// ── 1. 解析与校验请求体 ──
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "INVALID_BODY", message: "Request body must be JSON" },
{ status: 400 },
);
}
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "INVALID_INPUT",
message: "Email and password are required",
details: parsed.error.issues,
},
{ status: 400 },
);
}
// ── 2. 调用 iam 登录 ──
let iamResponse: Response;
try {
iamResponse = await fetch(IAM_LOGIN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
// 透传客户端 IP 与 UA 用于审计
...(req.headers.get("x-forwarded-for")
? { "X-Forwarded-For": req.headers.get("x-forwarded-for") as string }
: {}),
...(req.headers.get("user-agent")
? { "User-Agent": req.headers.get("user-agent") as string }
: {}),
},
body: JSON.stringify(parsed.data),
cache: "no-store",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(
`[portal-shell] /api/auth/login: iam unreachable: ${message} (url=${IAM_LOGIN_ENDPOINT})`,
);
return NextResponse.json(
{
error: "IAM_UNREACHABLE",
message: "Authentication service unavailable",
},
{ status: 502 },
);
}
// ── 3. 处理 iam 响应 ──
if (iamResponse.status === 401) {
return NextResponse.json(
{ error: "INVALID_CREDENTIALS", message: "邮箱或密码错误" },
{ status: 401 },
);
}
if (iamResponse.status === 429) {
return NextResponse.json(
{ error: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试" },
{ status: 429 },
);
}
if (!iamResponse.ok) {
// 其他错误403 账户锁定 / 5xx
let message = "登录失败";
try {
const errJson = (await iamResponse.json()) as { message?: string };
if (errJson.message) message = errJson.message;
} catch {
// 忽略 JSON 解析失败
}
return NextResponse.json(
{ error: "IAM_ERROR", message },
{ status: iamResponse.status },
);
}
// ── 4. 提取 token 与 user ──
let iamData: IamLoginResponse;
try {
iamData = (await iamResponse.json()) as IamLoginResponse;
} catch {
return NextResponse.json(
{ error: "IAM_BAD_RESPONSE", message: "登录服务返回数据异常" },
{ status: 502 },
);
}
const { user, tokens } = iamData.data;
if (!tokens?.accessToken || typeof tokens.expiresIn !== "number") {
return NextResponse.json(
{ error: "IAM_BAD_RESPONSE", message: "登录响应缺少 token" },
{ status: 502 },
);
}
// ── 5. 计算 cookie 值 ──
const secure = process.env.NODE_ENV === "production";
const maxAge = Math.min(tokens.expiresIn, 60 * 60 * 8); // 最长 8 小时
const knownPerms = filterKnownPermissions(user.permissions ?? []);
const permsBitmap = encodePermissionsBitmap(knownPerms);
// ── 6. 构建响应(不返回 token 给前端) ──
const response = NextResponse.json(
{
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: pickPrimaryRole(user.roles),
permissions: knownPerms,
dataScope: user.dataScope,
},
},
{ status: 200 },
);
// 设置 Set-Cookie 头(多 cookie 用逗号分隔NextResponse.cookies 更稳)
response.cookies.set(SESSION_COOKIE, tokens.accessToken, {
httpOnly: true,
secure,
sameSite: "strict",
path: "/",
maxAge,
});
response.cookies.set(PERMS_COOKIE, permsBitmap, {
httpOnly: false,
secure,
sameSite: "strict",
path: "/",
maxAge,
});
return response;
}
/**
* GET /api/auth/login → 简单状态端点(不暴露任何敏感信息)。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{ ok: true, endpoint: "/api/auth/login", method: "POST" },
{ status: 200 },
);
}

View File

@@ -0,0 +1,108 @@
/**
* 登出代理 Route HandlerP0-1ARCHITECTURE.md §3.4 V3-A2 / §4.2 / §4.3
*
* 流程:
* Browser POST /api/auth/logout携带 edu_session cookie
* → 本 Route Handler 从 cookie 取 access token
* → 清除 edu_session + edu_perms cookie无论 iam 是否成功)
* → best-effort 调 iam /api/v1/iam/logout带 Authorization使 refresh token 失效
* → 返回 { success: true },前端跳转 /login
*
* 容错策略:
* - iam 不可达 / 返回错误 → 静默忽略,仍然清 cookie用户体验优先本地登出必成功
* - iam 端的 edu_refresh httpOnly cookie 由 iam 自行清除path=/api/v1/iam本代理无法跨 path 清)
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.2、§4.3、§11.7 红线 #2
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const PERMS_COOKIE = "edu_perms";
const GATEWAY_URL =
process.env.API_GATEWAY_URL ||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
"http://localhost:8080";
const IAM_LOGOUT_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/logout`;
/**
* 解析 cookie 头中的指定 cookie。
*/
function readCookie(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${name}=`));
if (!match) return null;
return decodeURIComponent(match.slice(name.length + 1));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const cookieHeader = req.headers.get("cookie");
const token = readCookie(cookieHeader, SESSION_COOKIE);
const secure = process.env.NODE_ENV === "production";
// ── 1. best-effort 调 iam logout使服务端 refresh token 失效) ──
if (token) {
try {
await fetch(IAM_LOGOUT_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(req.headers.get("x-forwarded-for")
? {
"X-Forwarded-For": req.headers.get("x-forwarded-for") as string,
}
: {}),
...(req.headers.get("user-agent")
? { "User-Agent": req.headers.get("user-agent") as string }
: {}),
},
body: JSON.stringify({}),
cache: "no-store",
});
} catch (err) {
// iam 不可达 → 静默,本地登出仍然完成
const message = err instanceof Error ? err.message : "Unknown error";
console.warn(
`[portal-shell] /api/auth/logout: iam unreachable: ${message} (url=${IAM_LOGOUT_ENDPOINT})`,
);
}
}
// ── 2. 清除 edu_session + edu_perms cookie无论 iam 是否成功) ──
const response = NextResponse.json({ success: true }, { status: 200 });
response.cookies.set(SESSION_COOKIE, "", {
httpOnly: true,
secure,
sameSite: "strict",
path: "/",
maxAge: 0,
});
response.cookies.set(PERMS_COOKIE, "", {
httpOnly: false,
secure,
sameSite: "strict",
path: "/",
maxAge: 0,
});
return response;
}
/**
* GET /api/auth/logout → 简单状态端点(不暴露任何敏感信息)。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{ ok: true, endpoint: "/api/auth/logout", method: "POST" },
{ status: 200 },
);
}

View File

@@ -0,0 +1,147 @@
/**
* GraphQL 同域代理P0-3 + P1-5ARCHITECTURE.md §3.4 V3-A2/V3-A7 / §4 / §5.2 / §10 P1-5
*
* 浏览器 Apollo Client 一律走同域 `/api/graphql`
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
*
* MSW 兜底层P1-5
* - NEXT_PUBLIC_MSW=1 时SSR 端 Apollo Client 也走 /api/graphql见 apollo-client.ts
* - 本 Route Handler 检测 MSW 开关,开启时直接返回 mock 数据(不连接后端)
* - Mock 数据来自 mocks/graphql-data.ts与 MSW browser worker 共用
* - 生产构建 NEXT_PUBLIC_MSW 不为 "1",此分支被 tree-shake 移除
*
* 职责:
* 1. 从 httpOnly cookie `edu_session` 取 JWT注入 `Authorization: Bearer`
* 2. 透传 bodyAPQ hash 或 query与 Apollo 相关头
* 3. 响应 status / JSON 原样回传,不缓存
* 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`
*
* 安全收益§4.2
* - JWT 全程不出 httpOnly cookie消除 XSS 窃取凭证面
* - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router
*
* 验收命令:
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
* NEXT_PUBLIC_MSW=1 pnpm dev → 仪表盘/users 有数据,无需后端
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { graphqlResponse } from "@/mocks/graphql-data";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const UPSTREAM_URL =
process.env.APOLLO_ROUTER_URL ||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
/**
* 从 Cookie 头解析指定 cookie 值。
*/
function readCookie(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${name}=`));
if (!match) return null;
return decodeURIComponent(match.slice(name.length + 1));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
// P1-5 MSW 兜底层:开启时直接返回 mock 数据,不连接后端
if (MSW_ENABLED) {
const body = (await req.json().catch(() => ({}))) as {
operationName?: string;
variables?: Record<string, unknown>;
};
return NextResponse.json(
graphqlResponse(body.operationName, body.variables),
{ headers: { "Cache-Control": "no-store" } },
);
}
const cookieHeader = req.headers.get("cookie");
const token = readCookie(cookieHeader, SESSION_COOKIE);
// 透传 bodyAPQ hash 请求或完整 query不解析不修改
const body = await req.text();
const upstreamHeaders: Record<string, string> = {
"Content-Type": req.headers.get("content-type") ?? "application/json",
Accept: req.headers.get("accept") ?? "application/json",
// Apollo Persisted Query 协议头透传
"X-APQ": req.headers.get("x-apq") ?? "1",
// 服务端追踪:透传客户端 X-Request-Id若有
...(req.headers.get("x-request-id")
? { "X-Request-Id": req.headers.get("x-request-id") as string }
: {}),
};
// 注入 Authorization若 cookie 中有 JWT
if (token) {
upstreamHeaders.Authorization = `Bearer ${token}`;
}
try {
const upstream = await fetch(UPSTREAM_URL, {
method: "POST",
headers: upstreamHeaders,
body,
cache: "no-store",
});
const responseText = await upstream.text();
return new NextResponse(responseText, {
status: upstream.status,
headers: {
"Content-Type":
upstream.headers.get("content-type") ?? "application/json",
// 不缓存GraphQL 响应可能因身份/变量而异
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown upstream error";
console.error(
`[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`,
);
return NextResponse.json(
{
errors: [
{
message: "UPSTREAM_UNAVAILABLE",
extensions: {
code: "UPSTREAM_UNAVAILABLE",
reason: message,
},
},
],
},
{ status: 502 },
);
}
}
/**
* GET /api/graphql → 简单健康标识,便于排查路由是否挂载。
* Apollo Router 自身的 health 在 :8088/health。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{
ok: true,
proxy: "/api/graphql",
upstream: UPSTREAM_URL,
msw: MSW_ENABLED,
},
{ status: 200 },
);
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Liveness 健康检查project_rules §12
* GET /api/health — 进程存活即返回 200。
*/
export function GET(_request: NextRequest): NextResponse {
return NextResponse.json(
{ status: "ok", service: "portal-shell", timestamp: Date.now() },
{ status: 200 },
);
}

View File

@@ -0,0 +1,56 @@
/**
* 客户端错误上报端点mock 实现)
*
* 当前阶段:输出到 stdout便于开发调试
* 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log
*
* 端点POST /api/log
* Body: ErrorReportPayload见 @edu/hooks/use-error-report
*
* 关联portal-shell README v2.0 §5.4 三级错误处理
*/
import { NextResponse } from "next/server";
interface ErrorReportPayload {
level: "error" | "warning";
message: string;
stack?: string;
digest?: string;
path: string;
userAgent: string;
timestamp: string;
pluginId?: string;
userId?: string;
context?: Record<string, unknown>;
}
export async function POST(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as ErrorReportPayload;
// 开发阶段:结构化输出到 stdout
// 生产阶段:这里应替换为 OTel export 或 Sentry capture
console.error("[client-error]", {
level: payload.level,
message: payload.message,
digest: payload.digest,
path: payload.path,
pluginId: payload.pluginId,
userId: payload.userId,
timestamp: payload.timestamp,
// stack 太长,单独一行输出便于阅读
stack: payload.stack?.split("\n").slice(0, 5).join("\n"),
});
// 返回 204让 sendBeacon 认为成功
return new NextResponse(null, { status: 204 });
} catch {
// 解析失败也返回 204避免客户端重试
return new NextResponse(null, { status: 204 });
}
}
/** 健康检查 */
export function GET(): NextResponse {
return NextResponse.json({ ok: true, endpoint: "/api/log" });
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Readiness 健康检查project_rules §12
* GET /api/ready — 检查下游 apollo-router 是否可达。
*/
export async function GET(_request: NextRequest): Promise<NextResponse> {
const routerUrl =
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
process.env.APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
try {
const res = await fetch(routerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: "{ __typename }",
}),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
return NextResponse.json(
{ status: "degraded", router: routerUrl, code: res.status },
{ status: 503 },
);
}
return NextResponse.json(
{ status: "ready", router: routerUrl, timestamp: Date.now() },
{ status: 200 },
);
} catch (err) {
return NextResponse.json(
{
status: "not-ready",
router: routerUrl,
error: err instanceof Error ? err.message : String(err),
},
{ status: 503 },
);
}
}

View File

@@ -0,0 +1,62 @@
/**
* portal-shell 全局样式Tailwind v4 + shadcn 标准令牌)
*
* 引入 @edu/ui-tokens 三层设计令牌primitive → semantic → tailwind-theme
* 业务代码使用 Tailwind 类bg-background / text-foreground / bg-card ...)或 hsl(var(--*)) 引用。
*
* 禁止规则ESLint + project_rules §3.10
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
* - 禁止字体名字面量(用 var(--font-family-*))
* - 禁止 font-size: Npx用 var(--font-size-*) 或 Tailwind text-* 类)
*
* 对齐CICD 项目 src/app/globals.css
*/
@import "tailwindcss";
@import "@edu/ui-tokens/all.css";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
/* 排除非源码目录,防止文档中的 Tailwind 任意值语法字符串被误识别为类名 */
@source not "../../docs";
@source not "../../scripts";
@source not "../../tests";
/* Reduced Motion */
@layer base {
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
/* Base Styles */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-family-sans);
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-family-sans);
font-weight: var(--weight-semibold);
letter-spacing: -0.01em;
}
}

View File

@@ -0,0 +1,68 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Toaster } from "@/shared/components/ui/sonner";
/**
* 字体加载next/font/google self-host
*
* 通过 CSS 变量 --font-inter 暴露字体族。
* ui-tokens 的 primitive 层将 --font-family-sans 映射为 var(--font-inter, ...)。
* 禁止字体名字面量project_rules §3.10)。
*
* 对齐CICD 项目 src/app/layout.tsx仅 Intershadcn 标准)
*/
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: "Edu Portal Shell",
description: "K12 智慧教务平台 - 插件化仪表盘",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
/**
* RootLayoutP1-4next-intl 接入)
*
* 职责:
* - <html lang={locale}>:从 next-intl getLocale() 获取cookie 驱动)
* - NextIntlClientProvider将 messages 注入客户端useTranslations 全局可用
* - 字体变量 + 全局 Toaster
*
* 业务 ProvidersApollo/Auth/Theme在 ClientShell 中挂载spec §5.5)。
*
* suppressHydrationWarningThemeProvider 在客户端切换 .dark class
* 与 SSR 输出的 <html class=""> 不一致,需抑制 hydration 警告。
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A6、§8.7
*/
export default async function RootLayout({
children,
}: {
children: ReactNode;
}): Promise<ReactNode> {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning className={inter.variable}>
<body className="font-sans antialiased">
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<Toaster />
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
/**
* 登录表单P0-1ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §8.3
*
* 客户端组件shadcn 令牌登录表单。
* - 提交POST /api/auth/login { email, password }
* - 成功router.push(next || "/shell")
* - 失败notify.error 显示归一化错误
* - DEV_MODE显示提示横幅 + 一键填充 dev 凭证按钮
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§8.3、§11.7 红线 #2
*/
import { useState, useTransition, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { GraduationCap, Loader2, LogIn } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import { notify } from "@/shared/lib/notify";
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
interface LoginSuccessResponse {
success: true;
user: {
id: string;
email: string;
name: string;
role: string;
permissions: string[];
dataScope: string;
};
}
interface LoginErrorResponse {
error: string;
message: string;
}
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
/**
* 把后端返回的错误码映射为中文文案。
*/
function mapLoginError(errorCode: string, fallback: string): string {
switch (errorCode) {
case "INVALID_BODY":
case "INVALID_INPUT":
return "请输入有效的邮箱和密码";
case "INVALID_CREDENTIALS":
return "邮箱或密码错误";
case "RATE_LIMITED":
return "登录尝试过于频繁,请稍后再试";
case "IAM_UNREACHABLE":
case "IAM_BAD_RESPONSE":
return "登录服务暂不可用,请稍后再试";
case "IAM_ERROR":
return fallback || "登录失败,请重试";
default:
return fallback || "登录失败,请重试";
}
}
export function LoginForm(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
if (isPending) return;
startTransition(async () => {
const next = searchParams.get("next") || "/shell";
let response: Response;
try {
response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
credentials: "include",
});
} catch {
notify.error("网络错误,请检查网络连接后重试");
return;
}
let data: LoginResponse;
try {
data = (await response.json()) as LoginResponse;
} catch {
notify.error("登录服务返回数据异常");
return;
}
if (!response.ok || !("success" in data)) {
const errorResp = data as LoginErrorResponse;
notify.error(mapLoginError(errorResp.error, errorResp.message));
return;
}
notify.success(`欢迎回来,${data.user.name || data.user.email}`);
// 用 router.push 而非 location.href保留 SPA 体验cookie 已由 Set-Cookie 写入
router.push(next);
router.refresh();
});
}
function fillDevCredentials(): void {
setEmail("dev@edu.local");
setPassword("dev-password");
}
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2">
<div className="bg-primary text-primary-foreground flex size-12 items-center justify-center rounded-xl">
<GraduationCap className="size-6" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">Edu Portal</h1>
<p className="text-muted-foreground text-sm">K12 </p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit} noValidate>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
</label>
<Input
id="email"
type="email"
autoComplete="email"
required
disabled={isPending}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@school.edu.cn"
aria-label="邮箱"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
</label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
disabled={isPending}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
aria-label="密码"
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
className="w-full"
disabled={isPending || !email || !password}
>
{isPending ? (
<>
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
</>
) : (
<>
<LogIn className="size-4" aria-hidden="true" />
</>
)}
</Button>
{DEV_MODE ? (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={fillDevCredentials}
disabled={isPending}
>
dev
</Button>
) : null}
</CardFooter>
</form>
</Card>
<p className="text-muted-foreground mt-6 text-center text-xs">
Edu Portal 使
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
/**
* 登录页P0-1ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.2 / §7.1
*
* 路由:/login
*
* 职责:
* - RSC 入口检测已登录cookie edu_session 存在)→ redirect /shell
* - 渲染 <LoginForm /> 客户端组件
*
* 已登录检测策略:
* - middleware 将 /login 列入 PUBLIC_ROUTES不强制身份校验
* - 本页面 RSC 通过 cookies() 读 edu_session cookie仅看存在性不验签——验签由 middleware 负责)
* - 存在 cookie 视为已登录,直接 redirect /shell避免登录页"闪现"
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.2、§11.7 红线 #2
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { LoginForm } from "./login-form";
export const dynamic = "force-dynamic";
export default async function LoginPage(): Promise<React.ReactElement> {
const cookieStore = await cookies();
const session = cookieStore.get("edu_session");
if (session?.value) {
redirect("/shell");
}
return <LoginForm />;
}

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
/**
* 根路径重定向到 /shellportal-shell spec §8.2:路由前缀 /shell/*)。
*/
export default function RootPage(): never {
redirect("/shell");
}

View File

@@ -0,0 +1,59 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { fetchPluginConfig } from "@/lib/config-fetcher";
import { ClientShell } from "@/shell/ClientShell";
import type { Role } from "@/lib/types";
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
/**
* Shell catch-all 入口RSC Server Componentv3.0 P0-2 + P1-2
*
* 路由行为ARCHITECTURE.md §3.4 V3-A1 混合路由模型):
* - `/shell`(空路由)→ 302 重定向到 `/shell/{role}` 角色仪表盘P1-2
* - `/shell/{role}/{module}/...`(有路由段)→ 微内核仪表盘兜底
* P2-P5 将逐步替换为显式路由页面)
*
* fail-closedP0-2§11.7 红线 #5
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx禁止默认 teacher
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A1/A2/A3、§5.5、§10 P1-2、§11.7 红线 #5
*/
export default async function ShellPage({
params,
}: {
params: Promise<{ route?: string[] }>;
}): Promise<React.ReactElement> {
const headerList = await headers();
const userId = headerList.get("x-user-id");
const roleHeader = headerList.get("x-user-role");
// fail-closedmiddleware 必须注入身份头,缺失即异常(不再默认 teacher
if (!userId || !roleHeader) {
throw new Error(
"[portal-shell] ShellPage missing identity headers " +
"(middleware must inject x-user-id / x-user-role). " +
"If middleware is configured, this indicates a routing misconfiguration.",
);
}
const role = roleHeader as Role;
const { route } = await params;
// P1-2`/shell`(空路由)→ 重定向到角色仪表盘
// 显式路由页面(/shell/teacher、/shell/student 等)由 Next.js 优先匹配,
// 不会进入此 catch-all仅当用户直接访问 /shell 时重定向。
if (!route || route.length === 0) {
redirect(`/shell/${role}`);
}
// 有路由段时微内核仪表盘兜底P2-P5 将逐步替换为显式路由页面)
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
userId,
role,
);
return (
<ClientShell configPromise={configPromise} role={role} userId={userId} />
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { Activity, Building2, GraduationCap, Users } from "lucide-react";
import { useAdminDashboard } 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
*
* 改接 data-ana 的 adminDashboard 真实聚合查询,替换原
* announcements 假契约 widget 查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function AdminDashboardPage(): React.ReactElement {
const { data, loading, error } = useAdminDashboard();
if (loading) {
return (
<DashboardShell title="管理员仪表盘" 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="管理员仪表盘" description="全校概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="管理员仪表盘"
description="全校概览"
stats={
<>
<StatCard
title="教师总数"
value={data.total_teachers ?? "--"}
icon={Users}
/>
<StatCard
title="学生总数"
value={data.total_students ?? "--"}
icon={GraduationCap}
/>
<StatCard
title="班级总数"
value={data.total_classes ?? "--"}
icon={Building2}
/>
<StatCard
title="全校平均分"
value={data.school_avg_score?.toFixed(1) ?? "--"}
icon={Activity}
/>
</>
}
>
<DashboardSection title="近期预警" variant="list">
{data.recent_warnings ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.recent_warnings.target_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.recent_warnings.severity ?? "--"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.recent_warnings.warning_type ?? "--"} · {" "}
{data.recent_warnings.current_value?.toFixed(1) ?? "--"} / {" "}
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="AI 用量" variant="card">
{data.ai_usage ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{data.ai_usage.total_requests ?? "--"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"> Token</span>
<span className="font-medium">
{data.ai_usage.total_tokens ?? "--"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{data.ai_usage.total_cost_cents ?? "--"}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"> AI </p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,70 @@
import { notFound } from "next/navigation";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
/**
* 详情页模板示例ARCHITECTURE.md §10 P1-3
*
* /shell/dev/templates/detail — 仅 dev 可见
*/
export default function DetailTemplatePage(): React.ReactElement {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<DetailPageShell
title="2026 春季期末考试"
description="创建于 2026-07-15 · 进行中"
backHref="/shell/dev/templates"
actions={
<>
<Button variant="outline"></Button>
<Button variant="destructive"></Button>
</>
}
>
<DetailSection title="基本信息" description="考试的元数据">
<DetailField label="考试名称" value="2026 春季期末考试" />
<DetailField label="科目" value="数学" />
<DetailField label="年级" value="高一" />
<DetailField label="满分" value={150} />
<DetailField label="时长" value="120 分钟" />
<DetailField label="状态" value="进行中" />
</DetailSection>
<DetailSection
title="参与班级"
description="3 个班级共 120 名学生"
actions={
<Button variant="outline" size="sm">
</Button>
}
>
<div className="divide-y">
{[
{ name: "高一(1)班", count: 40 },
{ name: "高一(2)班", count: 42 },
{ name: "高一(3)班", count: 38 },
].map((c) => (
<div
key={c.name}
className="flex items-center justify-between py-2"
>
<span className="text-sm">{c.name}</span>
<span className="text-sm text-muted-foreground">
{c.count}
</span>
</div>
))}
</div>
</DetailSection>
</DetailPageShell>
);
}

View File

@@ -0,0 +1,69 @@
import { notFound } from "next/navigation";
import { FormPageShell } from "@/shared/components/page-templates";
/**
* 表单页模板示例ARCHITECTURE.md §10 P1-3
*
* /shell/dev/templates/form — 仅 dev 可见
*
* 演示:表单字段 + 提交/取消按钮 + 错误摘要
*/
export default function FormTemplatePage(): React.ReactElement {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<FormPageShell
title="新建考试"
description="填写考试基本信息(表单页模板示例)"
backHref="/shell/dev/templates"
onSubmit={() => {
// 示例:实际应调用 useExamCreate mutation
}}
submitting={false}
submitLabel="保存"
errorSummary={
// 示例:实际从 form.formState.errors 读取
undefined
}
>
<FormField label="考试名称" name="title" required />
<FormField label="科目" name="subject" required />
<FormField label="年级" name="grade" />
<FormField label="满分" name="maxScore" type="number" />
<FormField label="考试时长(分钟)" name="duration" type="number" />
</FormPageShell>
);
}
function FormField({
label,
name,
required = false,
type = "text",
}: {
label: string;
name: string;
required?: boolean;
type?: "text" | "number";
}): React.ReactElement {
return (
<div className="space-y-1.5">
<label
htmlFor={name}
className="block text-xs uppercase tracking-wide text-muted-foreground"
>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</label>
<input
id={name}
name={name}
type={type}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-1 focus-visible:ring-4"
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { notFound } from "next/navigation";
import { Button } from "@/shared/components/ui/button";
import { ListPageShell } from "@/shared/components/page-templates";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
/**
* 列表页模板示例ARCHITECTURE.md §10 P1-3
*
* /shell/dev/templates/list — 仅 dev 可见
*
* 展示三态loading / empty / success通过 query 参数 ?state= 切换)
* 默认显示 success 态的假数据表格。
*/
export default async function ListTemplatePage({
searchParams,
}: {
searchParams: Promise<{ state?: string }>;
}): Promise<React.ReactElement> {
if (process.env.NODE_ENV === "production") {
notFound();
}
const { state = "success" } = await searchParams;
return (
<ListPageShell
title="列表页模板"
description="PageHeader + FilterBar + DataTable + Pagination + 三态规范"
actions={<Button></Button>}
filters={
<FilterSearchInput placeholder="搜索..." value="" onChange={() => {}} />
}
loading={state === "loading"}
empty={state === "empty"}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span> 42 </span>
<Button variant="outline" size="sm">
</Button>
<span> 1 / 5 </span>
<Button variant="outline" size="sm">
</Button>
</div>
}
>
<ListTemplateTable />
</ListPageShell>
);
}
function ListTemplateTable(): React.ReactElement {
const rows = [
{
id: 1,
name: "考试 A",
subject: "数学",
status: "进行中",
createdAt: "2026-07-20",
},
{
id: 2,
name: "考试 B",
subject: "语文",
status: "已结束",
createdAt: "2026-07-18",
},
{
id: 3,
name: "考试 C",
subject: "英语",
status: "草稿",
createdAt: "2026-07-22",
},
];
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"></th>
<th className="p-3 text-left font-medium"></th>
<th className="p-3 text-left font-medium"></th>
<th className="p-3 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((r) => (
<tr key={r.id} className="hover:bg-muted/30">
<td className="p-3">{r.name}</td>
<td className="p-3">{r.subject}</td>
<td className="p-3">{r.status}</td>
<td className="p-3 font-mono">{r.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { PageHeader } from "@/shared/components/ui/page-header";
/**
* 模板预览首页ARCHITECTURE.md §10 P1-3 + P1-4 next-intl
*
* /shell/dev/templates — 仅 dev 可见
*
* 生产环境调用 notFound() 渲染 404避免模板示例暴露到线上。
* middleware 已放行 /shell/dev/**(仅校验登录),此文件做二次守卫。
*
* P1-4文案走 next-intl getTranslationsServer Component
* 切换 NEXT_LOCALE cookie 后页面文案即时切换。
*
* 注:原 ARCHITECTURE.md 使用 `_dev` 命名,但 Next.js 将下划线开头的文件夹
* 视为私有文件夹(不参与路由),故改用 `dev` 命名。
*/
export default async function TemplatesIndexPage(): Promise<React.ReactElement> {
if (process.env.NODE_ENV === "production") {
notFound();
}
const t = await getTranslations("shell.dev.templates");
const templates = [
{
href: "/shell/dev/templates/list",
title: t("list"),
description: t("listDescription"),
},
{
href: "/shell/dev/templates/detail",
title: t("detail"),
description: t("detailDescription"),
},
{
href: "/shell/dev/templates/form",
title: t("form"),
description: t("formDescription"),
},
{
href: "/shell/dev/templates/workbench",
title: t("workbench"),
description: t("workbenchDescription"),
},
];
return (
<div className="flex flex-col gap-6">
<PageHeader title={t("title")} description={t("description")} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{templates.map((tpl) => (
<Link
key={tpl.href}
href={tpl.href}
className="block rounded-xl border bg-card p-6 transition-colors hover:bg-accent"
>
<h2 className="text-lg font-semibold">{tpl.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{tpl.description}
</p>
<p className="mt-3 text-xs text-muted-foreground">{tpl.href}</p>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { notFound } from "next/navigation";
import { Button } from "@/shared/components/ui/button";
import {
WorkbenchPageShell,
WorkbenchPanel,
} from "@/shared/components/page-templates";
/**
* 工作台页模板示例ARCHITECTURE.md §10 P1-3
*
* /shell/dev/templates/workbench — 仅 dev 可见
*
* 演示:三栏(左:大纲树 / 中:画布 / 右:属性面板)
*/
export default function WorkbenchTemplatePage(): React.ReactElement {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<WorkbenchPageShell
title="教案编辑器"
description="工作台页模板示例(三栏布局)"
actions={
<>
<Button variant="outline"></Button>
<Button></Button>
</>
}
left={
<WorkbenchPanel
title="章节大纲"
actions={
<Button variant="ghost" size="sm">
+
</Button>
}
>
<OutlineTree />
</WorkbenchPanel>
}
center={
<WorkbenchPanel title="画布">
<CanvasDemo />
</WorkbenchPanel>
}
right={
<WorkbenchPanel title="节点属性">
<PropertiesPanel />
</WorkbenchPanel>
}
/>
);
}
function OutlineTree(): React.ReactElement {
const nodes = [
{ id: 1, title: "第一章 集合与函数", indent: 0 },
{ id: 2, title: "1.1 集合的概念", indent: 1 },
{ id: 3, title: "1.2 函数的定义", indent: 1 },
{ id: 4, title: "第二章 三角函数", indent: 0 },
{ id: 5, title: "2.1 任意角", indent: 1 },
];
return (
<ul className="space-y-1">
{nodes.map((n) => (
<li
key={n.id}
className="cursor-pointer rounded px-2 py-1 text-sm hover:bg-accent"
style={{ paddingLeft: `${n.indent * 12 + 8}px` }}
>
{n.title}
</li>
))}
</ul>
);
}
function CanvasDemo(): React.ReactElement {
return (
<div className="space-y-3">
<div className="rounded-md border border-dashed p-4">
<h3 className="text-base font-medium"> </h3>
<p className="mt-2 text-sm text-muted-foreground">
</p>
</div>
<div className="rounded-md border border-dashed p-4">
<h3 className="text-base font-medium"></h3>
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
<li>5 </li>
<li>15 </li>
<li> P5 10 </li>
<li>5 </li>
</ul>
</div>
</div>
);
}
function PropertiesPanel(): React.ReactElement {
return (
<div className="space-y-4">
<Field label="节点类型" value="章节" />
<Field label="标题" value="第一章 集合与函数" />
<Field label="预计时长" value="45 分钟" />
<Field label="教学方式" value="讲授 + 互动" />
<Field label="关联资源" value="教材 P1-P8" />
</div>
);
}
function Field({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="space-y-1">
<label className="block text-xs uppercase tracking-wide text-muted-foreground">
{label}
</label>
<div className="rounded-md border bg-background px-3 py-1.5 text-sm">
{value}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
/**
* Shell 路由错误兜底Next.js App Router error.tsx
*
* 触发条件:
* - RSC 渲染抛错(如 fetchPluginConfig 失败)
* - ClientShell 渲染抛错Provider 嵌套问题)
* - 任何子 segment 未捕获的错误
*
* 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级):
* 1. 隔离错误,避免整页白屏
* 2. 通过 useErrorReport 上报到 /api/log
* 3. 提供 reset 按钮重试
*
* 注意error.tsx 必须是 Client Component"use client"
*
* 关联Next.js App Router § error.tsx、portal-shell README v2.0 §5.4
*/
export default function ShellError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactNode {
return <RouteErrorBoundary error={error} reset={reset} namespace="Shell" />;
}

View File

@@ -0,0 +1,62 @@
import Link from "next/link";
import { ShieldX } from "lucide-react";
import { headers } from "next/headers";
/**
* 403 Forbidden 页P0-2ARCHITECTURE.md §3.4 V3-A3 / §4.2 / §6.2
*
* middleware 的 checkRoutePermission 拒绝时 302 重定向到此页。
* 页面渲染:
* - 友好提示 + 图标 + 返回仪表盘链接
* - 不暴露内部权限配置细节(仅显示通用 403 文案)
* - 支持查询参数 reason=no_config | missing_role | missing_permission仅 UX 提示)
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A3、§6.2、§11.7 红线 #5
*/
export default async function ForbiddenPage(): Promise<React.ReactElement> {
const headerList = await headers();
const userId = headerList.get("x-user-id") ?? "unknown";
const role = headerList.get("x-user-role") ?? "unknown";
return (
<main
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
role="alert"
aria-live="assertive"
>
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldX className="h-8 w-8" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold leading-tight">
403 · 访
</h1>
<p className="max-w-md text-sm text-muted-foreground">
访
</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono">{userId}</span>
{role !== "unknown" && (
<>
{" · "}<span className="font-mono">{role}</span>
</>
)}
</p>
</div>
<nav className="flex gap-3" aria-label="操作">
<Link
href="/shell"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</Link>
</nav>
</main>
);
}

View File

@@ -0,0 +1,54 @@
import { headers } from "next/headers";
import type { Role } from "@edu/shared-ts/contracts";
import { ShellSidebar } from "@/shared/components/layout/shell-sidebar";
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
import { SiteHeader } from "@/shared/components/layout/site-header";
import { UserMenu } from "@/shared/components/layout/user-menu";
/**
* AppFrame - 全站页面框架RSCARCHITECTURE.md §7.2 / §10 P1-1
*
* 结构SidebarProvider > ShellSidebar + (SiteHeader + main)
*
* fail-closedP0-2§11.7 红线 #5
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role / x-user-permissions
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx禁止默认 teacher
*
* RSC 边界P1-1 修正):
* - 本 layout 是 RSC仅向 Client Components 传可序列化数据string
* - 导航项过滤 + icon 函数引用由 ShellSidebarClient Component持有
* - 不直接传 lucide icon 或 hasPermission 函数给 Client Components
*
* 关联portal-shell ARCHITECTURE.md §7.2、§10 P1-1、§11.7 红线 #5
*/
export default async function ShellLayout({
children,
}: {
children: React.ReactNode;
}): Promise<React.ReactElement> {
const headerList = await headers();
const userId = headerList.get("x-user-id");
const roleHeader = headerList.get("x-user-role");
const permsBitmap = headerList.get("x-user-permissions");
// fail-closedmiddleware 必须注入身份头,缺失即异常
if (!userId || !roleHeader || !permsBitmap) {
throw new Error(
"[portal-shell] ShellLayout missing identity headers " +
"(middleware must inject x-user-id / x-user-role / x-user-permissions).",
);
}
const role = roleHeader as Role;
return (
<SidebarProvider>
<ShellSidebar role={role} permsBitmap={permsBitmap} />
<div className="flex flex-1 flex-col">
<SiteHeader actions={<UserMenu userId={userId} role={role} />} />
<main className="flex-1 overflow-y-auto p-4">{children}</main>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,104 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Shell 路由加载兜底Next.js App Router loading.tsx
*
* 触发条件:
* - RSC 正在解析fetchPluginConfig 等待中)
* - 路由切换时的过渡态
*
* 职责:
* - 整页骨架占位,避免白屏闪烁
* - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区)
*
* 流式渲染上下文portal-shell README v2.0 §5.3
* - loading.tsx 在 RSC Promise resolve 之前显示
* - 配合 PluginBoundarywidget 级 Suspense形成多层流式体验
*
* 关联Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3
*/
export default function ShellLoading(): React.ReactNode {
return (
<div className="flex min-h-screen flex-col bg-background">
{/* 顶栏 */}
<header className="border-b bg-card">
<div className="flex h-16 items-center gap-3 px-6">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-6 w-32" />
<div className="ml-auto flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="size-9 rounded-full" />
</div>
</div>
</header>
<div className="flex flex-1">
{/* 侧栏 */}
<aside className="w-64 border-r bg-card p-4">
<div className="space-y-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-4 flex-1" />
</div>
))}
</div>
</aside>
{/* 主区:仪表盘骨架 */}
<main className="flex-1 p-6">
{/* 标题区 */}
<div className="mb-6 flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-9 w-24 rounded-md" />
</div>
{/* 统计卡片网格 */}
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border bg-card p-6">
<Skeleton className="mb-3 h-4 w-24" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* 内容区:图表 + 列表 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
<Skeleton className="mb-4 h-6 w-32" />
<div className="flex h-64 items-end gap-2">
{[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map(
(h, i) => (
<Skeleton
key={i}
className="flex-1 rounded-t"
style={{ height: `${h}%` }}
/>
),
)}
</div>
</div>
<div className="rounded-xl border bg-card p-6">
<Skeleton className="mb-4 h-6 w-24" />
<div className="space-y-3">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { GraduationCap, TrendingUp } from "lucide-react";
import { useParentDashboard } 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
*
* 改接 data-ana 的 parentDashboard 真实聚合查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function ParentDashboardPage(): React.ReactElement {
const { data, loading, error } = useParentDashboard();
if (loading) {
return (
<DashboardShell title="家长仪表盘" 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="家长仪表盘" description="孩子学习概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="家长仪表盘"
description="孩子学习概览"
stats={
<>
<StatCard
title="孩子平均分"
value={data.child_avg_score?.toFixed(1) ?? "--"}
icon={GraduationCap}
/>
<StatCard
title="班级排名"
value={`${data.child_class_rank ?? "--"} / ${data.total_class_students ?? "--"}`}
icon={TrendingUp}
/>
</>
}
>
<DashboardSection title="薄弱知识点" variant="list">
{data.child_weak_points ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.child_weak_points.title ?? "--"}
</span>
<span className="text-muted-foreground">
{data.child_weak_points.mastery?.toFixed(1) ?? "--"}%
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.child_weak_points.error_count ?? 0}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="预警通知" variant="list">
{data.child_warnings ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.child_warnings.target_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.child_warnings.severity ?? "--"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.child_warnings.warning_type ?? "--"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react";
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
*
* 改接 data-ana 的 studentDashboard 真实聚合查询,替换原
* grades/homeworks/schedule/exams 假契约 widget 查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function StudentDashboardPage(): React.ReactElement {
const { data, loading, error } = useStudentDashboard();
if (loading) {
return (
<DashboardShell title="学生仪表盘" 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="学生仪表盘" description="学习概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="学生仪表盘"
description="学习概览"
stats={
<>
<StatCard
title="平均分"
value={data.avg_score?.toFixed(1) ?? "--"}
icon={GraduationCap}
/>
<StatCard
title="班级排名"
value={`${data.class_rank ?? "--"} / ${data.total_students ?? "--"}`}
icon={TrendingUp}
/>
<StatCard
title="待交作业"
value={data.pending_homework ?? 0}
icon={BookOpen}
highlight={(data.pending_homework ?? 0) > 0}
/>
</>
}
>
<DashboardSection title="薄弱知识点" 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">
{data.weak_points.mastery?.toFixed(1) ?? "--"}%
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.weak_points.error_count ?? 0}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="近期成绩趋势" variant="chart">
{data.recent_trends ? (
<div className="text-sm">
<span className="text-muted-foreground">
{data.recent_trends.date ?? "--"}
</span>
<span className="font-medium">
{data.recent_trends.score?.toFixed(1) ?? "--"}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { ExamAnalyticsClient } from "@/features/teacher/exams/exam-analytics-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 考试分析页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ExamAnalyticsClientclient component中。
*
* 数据契约:混合契约
* - 基础统计 ✅ assignmentAnalysisdata-ana 子图schema 已就绪)
* - 扩展字段(排名/每题正确率)❌ → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ExamAnalyticsPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ExamAnalyticsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { ExamBuildClient } from "@/features/teacher/exams/exam-build-client";
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
/**
* 组卷工作台页ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ExamBuildClientclient component中。
*
* 数据契约:❌ @contract-pending
* - examBuild(examId) 根字段不存在 → MSW 兜底
* - questionsLibrary(filter) 根字段不存在 → MSW 兜底
* - saveExamBuild(input) mutation 不存在 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ExamBuildPage(): React.ReactElement {
return (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<ExamBuildClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { ExamEditClient } from "@/features/teacher/exams/exam-edit-client";
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
/**
* 富文本试卷编辑页ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ExamEditClientclient component中。
*
* 数据契约:❌ @contract-pending
* - examRichEditor(examId) 根字段不存在 → MSW 兜底
* - saveExamRichContent(input) mutation 不存在 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ExamEditPage(): React.ReactElement {
return (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<ExamEditClient />
</Suspense>
);
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { ExamDetailClient } from "@/features/teacher/exams/exam-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 考试详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ExamDetailClientclient component中。
*
* 数据契约:单查 exam(id: ID!) ✅ schema 真实字段core-edu 子图)
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
*/
export default function ExamDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ExamDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
/**
* 考试路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
export default function ExamsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
useEffect(() => {
console.error("[portal-shell] exams route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive"></h2>
<p className="text-sm text-muted-foreground">
{error.message || "未知错误"}
</p>
<Button onClick={reset} variant="outline">
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 考试列表页加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function ExamsLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { NewExamClient } from "@/features/teacher/exams/new-exam-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 新建考试表单页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 NewExamClientclient component中。
*
* 数据契约mutation createExam(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-exam-mutation
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function NewExamPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<NewExamClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { ExamsListClient } from "@/features/teacher/exams/exams-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 考试管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 ExamsListClientclient component中。
*
* 数据契约:列表查询 exams(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#exams-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ExamsListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<ExamsListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { GradeAnalyticsClient } from "@/features/teacher/grades/grade-analytics-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 成绩分析页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 GradeAnalyticsClientclient component中。
*
* 数据契约:混合契约
* - 基础统计 ✅ assignmentAnalysisdata-ana 子图schema 已就绪)
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function GradeAnalyticsPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<GradeAnalyticsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { GradeEntryClient } from "@/features/teacher/grades/grade-entry-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 成绩录入表单页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 GradeEntryClientclient component中。
*
* 数据契约mutation createGrade(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function GradeEntryPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<GradeEntryClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 成绩路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
export default function GradesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("grades");
useEffect(() => {
console.error("[portal-shell] grades route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 成绩路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*
* 子页面(录入/分析/统计/成绩单)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
* 本文件仅在 /shell/teacher/grades 列表/重定向期间显示。
*/
export default function GradesLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { GradesListClient } from "@/features/teacher/grades/grades-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 成绩管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 GradesListClientclient component中。
*
* 数据契约:列表查询 grades(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#grades-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function GradesListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<GradesListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { ReportCardClient } from "@/features/teacher/grades/report-card-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 学生成绩单页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ReportCardClientclient component中。
*
* 数据契约reportCard(studentId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#report-card
*
* 关联ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ReportCardPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ReportCardClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { GradeStatsClient } from "@/features/teacher/grades/grade-stats-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 成绩统计页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 GradeStatsClientclient component中。
*
* 数据契约gradeStats(classId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#grade-stats
*
* 关联ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function GradeStatsPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<GradeStatsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { HomeworkDetailClient } from "@/features/teacher/homework/homework-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 作业详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 HomeworkDetailClientclient component中。
*
* 数据契约:
* - 单查 homework(id: ID!) ✅ schema 真实字段core-edu 子图)
* - 提交列表 homeworkSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底
* - mutation recordGrade(input) ❌ schema 无 Mutation → MSW 兜底
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function HomeworkDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<HomeworkDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { AssignmentSubmissionsClient } from "@/features/teacher/homework/assignment-submissions-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 按作业批量批改页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 AssignmentSubmissionsClientclient component中。
*
* 数据契约:
* - 查询 assignmentSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 查询 aiBatchGrading(homeworkId) ❌ schema 无此字段 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AssignmentSubmissionsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={6} />}>
<AssignmentSubmissionsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 作业路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function HomeworkError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("homework");
useEffect(() => {
console.error("[portal-shell] homework route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 作业路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*
* 子页面(详情/批改/扫描批改)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
* 本文件仅在 /shell/teacher/homework 列表/重定向期间显示。
*/
export default function HomeworkLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { NewHomeworkClient } from "@/features/teacher/homework/new-homework-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 布置作业表单页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 NewHomeworkClientclient component中。
*
* 数据契约mutation assignHomework(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function NewHomeworkPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<NewHomeworkClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { HomeworkListClient } from "@/features/teacher/homework/homework-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 作业管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 HomeworkListClientclient component中。
*
* 数据契约:列表查询 homeworks(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function HomeworkListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<HomeworkListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { SubmissionGradingClient } from "@/features/teacher/homework/submission-grading-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 单份提交批改页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 SubmissionGradingClientclient component中。
*
* 数据契约:
* - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation gradeSubmission(input) ❌ schema 无 Mutation → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function SubmissionGradingPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<SubmissionGradingClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { ScanGradingClient } from "@/features/teacher/homework/scan-grading-client";
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
/**
* 扫描批改页ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ScanGradingClientclient component中。
*
* 数据契约:
* - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation saveScanGrading(input) ❌ schema 无 Mutation → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ScanGradingPage(): React.ReactElement {
return (
<Suspense fallback={<WorkbenchPageSkeleton />}>
<ScanGradingClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { SubmissionsListClient } from "@/features/teacher/homework/submissions-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 提交批改列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 SubmissionsListClientclient component中。
*
* 数据契约:列表查询 homeworkSubmissions(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function HomeworkSubmissionsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={8} />}>
<SubmissionsListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
import { useTeacherDashboard } 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
*
* 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function TeacherDashboardPage(): React.ReactElement {
const { data, loading, error } = useTeacherDashboard();
if (loading) {
return (
<DashboardShell title="教师仪表盘" 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="教师仪表盘" description="今日教学概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="教师仪表盘"
description="今日教学概览"
stats={
<>
<StatCard
title="班级总数"
value={data.total_classes ?? "--"}
icon={Users}
/>
<StatCard
title="学生总数"
value={data.total_students ?? "--"}
icon={GraduationCap}
/>
<StatCard
title="班级平均分"
value={data.class_avg_score?.toFixed(1) ?? "--"}
icon={Activity}
/>
<StatCard
title="待批作业"
value={data.pending_homework_count ?? 0}
icon={BookOpen}
highlight={(data.pending_homework_count ?? 0) > 0}
/>
</>
}
>
<DashboardSection title="班级概况" variant="card">
{data.classes ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{data.classes.class_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.classes.student_count ?? 0} · {" "}
{data.classes.average_score?.toFixed(1) ?? "--"}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="近期预警" variant="list">
{data.recent_warnings ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.recent_warnings.target_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.recent_warnings.severity ?? "--"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.recent_warnings.warning_type ?? "--"} · {" "}
{data.recent_warnings.current_value?.toFixed(1) ?? "--"} / {" "}
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,353 @@
/**
* Exams 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Exam } from "@/lib/api";
import {
EXAM_STATUS_LABEL,
countByType,
formatDuration,
formatExamDate,
formatExamStatus,
formatPassRate,
formatPercent,
formatScore,
isExamEditable,
isExamPublished,
levelToColorClass,
nextSortOrder,
parseTotalScore,
rateToColorClass,
rateToLevel,
sortBySortOrder,
sumSelectedScores,
toExamListItem,
} from "../transformations";
describe("formatExamStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatExamStatus("DRAFT")).toBe("草稿");
expect(formatExamStatus("PUBLISHED")).toBe("已发布");
expect(formatExamStatus("IN_PROGRESS")).toBe("进行中");
expect(formatExamStatus("GRADING")).toBe("批改中");
expect(formatExamStatus("SCORED")).toBe("已完成");
expect(formatExamStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatExamStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatExamStatus("")).toBe("");
});
it("EXAM_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(EXAM_STATUS_LABEL)).toHaveLength(6);
});
});
describe("formatExamDate", () => {
it("formats valid ISO date string", () => {
const result = formatExamDate("2026-07-22T10:00:00Z");
// 不同时区下日期字符串会有差异,但应包含 2026 与 07
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatExamDate(null)).toBe("--");
expect(formatExamDate(undefined)).toBe("--");
expect(formatExamDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatExamDate("not-a-date")).toBe("--");
});
});
describe("parseTotalScore", () => {
it("parses numeric string", () => {
expect(parseTotalScore("100")).toBe(100);
expect(parseTotalScore("0")).toBe(0);
});
it("passes through number input", () => {
expect(parseTotalScore(150)).toBe(150);
});
it("returns 0 for non-numeric string", () => {
expect(parseTotalScore("abc")).toBe(0);
});
it("returns 0 for NaN", () => {
expect(parseTotalScore(Number.NaN)).toBe(0);
});
});
describe("isExamEditable / isExamPublished", () => {
it("DRAFT is editable but not published", () => {
expect(isExamEditable("DRAFT")).toBe(true);
expect(isExamPublished("DRAFT")).toBe(false);
});
it("PUBLISHED and later statuses are published but not editable", () => {
for (const s of [
"PUBLISHED",
"IN_PROGRESS",
"GRADING",
"SCORED",
"ARCHIVED",
]) {
expect(isExamEditable(s)).toBe(false);
expect(isExamPublished(s)).toBe(true);
}
});
it("unknown status is neither editable nor published", () => {
expect(isExamEditable("UNKNOWN")).toBe(false);
expect(isExamPublished("UNKNOWN")).toBe(false);
});
});
describe("toExamListItem", () => {
it("extracts list fields from full exam", () => {
const exam: Exam = {
id: "exam-001",
classId: "cls-001",
subjectId: "sub-math",
title: "期中考试",
description: "包含集合与函数",
examDate: "2026-07-22T10:00:00Z",
duration: 120,
totalScore: "100",
status: "DRAFT",
statusChangedAt: "2026-07-20T00:00:00Z",
statusChangedBy: "usr-001",
schoolId: "sch-001",
createdBy: "usr-001",
createdAt: "2026-07-19T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toExamListItem(exam);
expect(item.id).toBe("exam-001");
expect(item.title).toBe("期中考试");
expect(item.status).toBe("DRAFT");
// 裁剪掉的字段不在列表项类型上
expect(item).not.toHaveProperty("statusChangedAt");
expect(item).not.toHaveProperty("schoolId");
expect(item).not.toHaveProperty("createdBy");
});
it("handles null description", () => {
const exam: Exam = {
id: "exam-002",
classId: "cls-001",
subjectId: "sub-math",
title: "无描述考试",
description: null,
examDate: "2026-07-22T10:00:00Z",
duration: 60,
totalScore: "50",
status: "PUBLISHED",
statusChangedAt: "2026-07-20T00:00:00Z",
statusChangedBy: null,
schoolId: "sch-001",
createdBy: "usr-001",
createdAt: "2026-07-19T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toExamListItem(exam);
expect(item.description).toBeNull();
});
});
describe("formatDuration", () => {
it("formats minutes under 60", () => {
expect(formatDuration(30)).toBe("30 分钟");
expect(formatDuration(45)).toBe("45 分钟");
expect(formatDuration(1)).toBe("1 分钟");
});
it("formats exact hours", () => {
expect(formatDuration(60)).toBe("1 小时");
expect(formatDuration(120)).toBe("2 小时");
});
it("formats hours with remainder minutes", () => {
expect(formatDuration(90)).toBe("1 小时 30 分钟");
expect(formatDuration(75)).toBe("1 小时 15 分钟");
});
it("returns placeholder for invalid input", () => {
expect(formatDuration(0)).toBe("--");
expect(formatDuration(-1)).toBe("--");
expect(formatDuration(Number.NaN)).toBe("--");
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--");
});
});
// ── Analytics 纯函数单测 ──────────────────────────────────────
describe("formatScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatScore(82.5)).toBe("82.5");
expect(formatScore(98)).toBe("98.0");
expect(formatScore(0)).toBe("0.0");
});
it("returns placeholder for non-finite input", () => {
expect(formatScore(Number.NaN)).toBe("--");
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
});
});
describe("formatPassRate / formatPercent", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatPassRate(0.86)).toBe("86%");
expect(formatPassRate(0)).toBe("0%");
expect(formatPassRate(1)).toBe("100%");
expect(formatPercent(0.92)).toBe("92%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatPassRate(-0.1)).toBe("--");
expect(formatPassRate(1.1)).toBe("--");
expect(formatPassRate(Number.NaN)).toBe("--");
expect(formatPercent(Number.NaN)).toBe("--");
});
});
describe("rateToLevel", () => {
it("maps rate to A/B/C/D levels", () => {
expect(rateToLevel(0.9)).toBe("A");
expect(rateToLevel(0.85)).toBe("A");
expect(rateToLevel(0.75)).toBe("B");
expect(rateToLevel(0.7)).toBe("B");
expect(rateToLevel(0.65)).toBe("C");
expect(rateToLevel(0.6)).toBe("C");
expect(rateToLevel(0.5)).toBe("D");
expect(rateToLevel(0)).toBe("D");
});
it("returns placeholder for non-finite input", () => {
expect(rateToLevel(Number.NaN)).toBe("--");
});
});
describe("rateToColorClass", () => {
it("returns emerald for high rates", () => {
expect(rateToColorClass(0.8)).toBe("text-emerald-600");
expect(rateToColorClass(0.95)).toBe("text-emerald-600");
});
it("returns amber for medium rates", () => {
expect(rateToColorClass(0.6)).toBe("text-amber-600");
expect(rateToColorClass(0.79)).toBe("text-amber-600");
});
it("returns destructive for low rates", () => {
expect(rateToColorClass(0.59)).toBe("text-destructive");
expect(rateToColorClass(0)).toBe("text-destructive");
});
it("returns muted for non-finite input", () => {
expect(rateToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("levelToColorClass", () => {
it("maps each level to correct color class", () => {
expect(levelToColorClass("A")).toBe("text-emerald-600");
expect(levelToColorClass("B")).toBe("text-blue-600");
expect(levelToColorClass("C")).toBe("text-amber-600");
expect(levelToColorClass("D")).toBe("text-destructive");
});
it("returns muted for unknown level", () => {
expect(levelToColorClass("X")).toBe("text-muted-foreground");
expect(levelToColorClass("")).toBe("text-muted-foreground");
});
});
// ── Build 纯函数单测 ──────────────────────────────────────────
describe("sumSelectedScores", () => {
it("sums all scores", () => {
expect(
sumSelectedScores([{ score: 10 }, { score: 15 }, { score: 20 }]),
).toBe(45);
});
it("returns 0 for empty array", () => {
expect(sumSelectedScores([])).toBe(0);
});
it("handles single item", () => {
expect(sumSelectedScores([{ score: 5 }])).toBe(5);
});
});
describe("countByType", () => {
it("counts items per type", () => {
const items = [
{ type: "single_choice" },
{ type: "single_choice" },
{ type: "multiple_choice" },
{ type: "fill_blank" },
];
const result = countByType(items);
expect(result.single_choice).toBe(2);
expect(result.multiple_choice).toBe(1);
expect(result.fill_blank).toBe(1);
});
it("returns empty object for empty array", () => {
expect(countByType([])).toEqual({});
});
});
describe("sortBySortOrder", () => {
it("sorts ascending by sortOrder", () => {
const items = [
{ sortOrder: 3, id: "c" },
{ sortOrder: 1, id: "a" },
{ sortOrder: 2, id: "b" },
];
const result = sortBySortOrder(items);
expect(result.map((i) => i.id)).toEqual(["a", "b", "c"]);
});
it("does not mutate the original array", () => {
const items = [
{ sortOrder: 2, id: "b" },
{ sortOrder: 1, id: "a" },
];
const original = [...items];
sortBySortOrder(items);
expect(items).toEqual(original);
});
it("returns empty array for empty input", () => {
expect(sortBySortOrder([])).toEqual([]);
});
});
describe("nextSortOrder", () => {
it("returns 1 for empty array", () => {
expect(nextSortOrder([])).toBe(1);
});
it("returns max + 1 for non-empty array", () => {
expect(
nextSortOrder([{ sortOrder: 1 }, { sortOrder: 3 }, { sortOrder: 2 }]),
).toBe(4);
});
it("handles single item", () => {
expect(nextSortOrder([{ sortOrder: 5 }])).toBe(6);
});
});

View File

@@ -0,0 +1,342 @@
"use client";
/**
* 考试分析页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(混合契约):
* - 基础统计 ✅ assignmentAnalysisdata-ana 子图schema 已就绪)
* - 扩展字段(排名/每题正确率/分布)❌ → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3, FileText } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useExamAnalytics } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
} from "@/shared/components/page-templates";
import {
formatPassRate,
formatPercent,
formatScore,
} from "@/features/teacher/exams/transformations";
/**
* 分析客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ExamAnalyticsClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
// 混合契约:基础统计真实 + 扩展字段 MSW
const { data, loading, error } = useExamAnalytics(examId);
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={data?.examTitle ?? t("analytics.title")}
description={
data ? t("analytics.subtitle", { examId: data.examId }) : undefined
}
icon={<BarChart3 className="size-6" />}
backHref={`/shell/teacher/exams/${examId}`}
actions={
<Button variant="outline" type="button">
<FileText className="mr-1 size-4" />
{t("analytics.exportCsv")}
</Button>
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("analytics.notFound")}
</div>
) : undefined
}
>
{data ? <ExamAnalyticsBody data={data} /> : null}
</DetailPageShell>
);
}
/**
* 分析内容区(汇总卡片 + 分布图 + 每题正确率 + 学生排名)。
*/
function ExamAnalyticsBody({
data,
}: {
data: NonNullable<ReturnType<typeof useExamAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("exams");
return (
<>
<DetailSection title={t("analytics.sectionSummary")}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<SummaryCard
label={t("analytics.summaryExpected")}
value={String(data.summary.expectedCount)}
/>
<SummaryCard
label={t("analytics.summaryAttended")}
value={String(data.summary.attendedCount)}
/>
<SummaryCard
label={t("analytics.summaryAvg")}
value={formatScore(data.summary.avgScore)}
/>
<SummaryCard
label={t("analytics.summaryMax")}
value={formatScore(data.summary.maxScore)}
/>
<SummaryCard
label={t("analytics.summaryMin")}
value={formatScore(data.summary.minScore)}
/>
<SummaryCard
label={t("analytics.summaryPassRate")}
value={formatPassRate(data.summary.passRate)}
/>
</div>
</DetailSection>
<DetailSection title={t("analytics.sectionDistribution")}>
<DistributionChart items={data.distribution} />
</DetailSection>
<DetailSection title={t("analytics.sectionQuestionAccuracy")}>
<QuestionAccuracyTable items={data.questionAccuracy} />
</DetailSection>
<DetailSection title={t("analytics.sectionRankings")}>
<RankingsTable items={data.rankings} />
</DetailSection>
</>
);
}
/**
* 汇总卡片(单字段)。
*/
function SummaryCard({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold">{value}</p>
</div>
);
}
/**
* 分数段分布柱状图(纯 SVG无外部图表库依赖
*/
function DistributionChart({
items,
}: {
items: Array<{ label: string; count: number }>;
}): React.ReactElement {
const t = useTranslations("exams");
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyDistribution")}
</p>
);
}
const maxCount = Math.max(...items.map((i) => i.count), 1);
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-3">
<span className="w-16 shrink-0 text-sm text-muted-foreground">
{item.label}
</span>
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
</div>
))}
</div>
);
}
/**
* 每题正确率表格。
*/
function QuestionAccuracyTable({
items,
}: {
items: Array<{
questionId: string;
order: number;
questionTitle: string;
correctRate: number;
avgScore: number;
maxScore: number;
}>;
}): React.ReactElement {
const t = useTranslations("exams");
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyQuestionAccuracy")}
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("analytics.colOrder")}</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colQuestionTitle")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colCorrectRate")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colAvgScore")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colMaxScore")}
</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.questionId} className="border-b last:border-0">
<td className="py-2 pr-4">{item.order}</td>
<td className="py-2 pr-4">{item.questionTitle}</td>
<td className="py-2 pr-4">
<span className="inline-flex items-center gap-2">
<span
className={
item.correctRate >= 0.8
? "text-emerald-600"
: item.correctRate >= 0.6
? "text-amber-600"
: "text-destructive"
}
>
{formatPercent(item.correctRate)}
</span>
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<span
className="block h-full bg-current"
style={{ width: `${item.correctRate * 100}%` }}
/>
</span>
</span>
</td>
<td className="py-2 pr-4">{formatScore(item.avgScore)}</td>
<td className="py-2 pr-4">{formatScore(item.maxScore)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 学生排名表格。
*/
function RankingsTable({
items,
}: {
items: Array<{
studentId: string;
studentNo: string;
studentName: string;
totalScore: number;
rank: number;
level: string;
}>;
}): React.ReactElement {
const t = useTranslations("exams");
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyRankings")}
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colStudentNo")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colStudentName")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colTotalScore")}
</th>
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.studentId} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{item.rank}</td>
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
<td className="py-2 pr-4">{item.studentName}</td>
<td className="py-2 pr-4">{formatScore(item.totalScore)}</td>
<td className="py-2 pr-4">
<span
className={
item.level === "A"
? "text-emerald-600"
: item.level === "B"
? "text-blue-600"
: item.level === "C"
? "text-amber-600"
: "text-destructive"
}
>
{item.level}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,499 @@
"use client";
/**
* 组卷工作台页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - examBuild(examId) ❌ → MSW 兜底
* - questionsLibrary(filter) ❌ → MSW 兜底
* - saveExamBuild(input) mutation ❌ → MSW 兜底
*
* 三栏布局WorkbenchPageShell
* - left题库候选列表带 q/type/difficulty 筛选)
* - center已选题目列表可上移/下移/编辑分值/移除)
* - right预览总分、题型分布、及格分提示
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { PencilLine, Plus, Trash2 } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import {
useExamBuild,
useQuestionsLibrary,
useSaveExamBuild,
type ExamBuildNode,
type QuestionsLibraryFilter,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import {
WorkbenchPageShell,
WorkbenchPageSkeleton,
WorkbenchPanel,
} from "@/shared/components/page-templates";
import {
countByType,
nextSortOrder,
sortBySortOrder,
sumSelectedScores,
} from "@/features/teacher/exams/transformations";
/**
* 组卷工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ExamBuildClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
// 已选题目(本地状态,从 useExamBuild 初始化)
const { data, loading, error } = useExamBuild(examId);
const [selected, setSelected] = useState<ExamBuildNode[] | null>(null);
// 首次拿到数据时初始化本地状态useEffect 处理副作用,不用 useMemo
useEffect(() => {
if (data && selected === null) {
setSelected(sortBySortOrder(data.selected));
}
}, [data, selected]);
const saveMutation = useSaveExamBuild();
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
// (工作台页"无数据"等价于"无法工作",合并表达更直接)
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>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("build.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
if (!selected) return;
try {
await saveMutation.run({
examId,
questions: selected.map((q) => ({
questionId: q.questionId,
score: q.score,
sortOrder: q.sortOrder,
})),
});
notify.success(t("build.saveSuccess"));
} catch (err) {
notify.error(t("build.saveFailed", { message: String(err) }));
}
};
return (
<WorkbenchPageShell
title={data?.title ?? t("build.title")}
description={
data
? t("build.subtitle", {
total: sumSelectedScores(selected ?? data.selected),
pass: data.passScore,
})
: undefined
}
icon={<PencilLine className="size-6" />}
actions={
<Button
type="button"
disabled={saveMutation.loading || !selected}
onClick={handleSave}
>
{saveMutation.loading ? tCommon("status.loading") : t("build.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
left={
data && selected ? (
<QuestionsLibraryPanel
onAdd={(item) => {
if (selected.some((s) => s.questionId === item.questionId)) {
notify.warning(t("build.alreadyAdded"));
return;
}
const newNode: ExamBuildNode = {
questionId: item.questionId,
score: item.score,
sortOrder: nextSortOrder(selected),
content: item.content,
type: item.type,
difficulty: item.difficulty,
};
setSelected([...selected, newNode]);
}}
/>
) : null
}
center={
data && selected ? (
<SelectedQuestionsPanel selected={selected} onChange={setSelected} />
) : null
}
right={
data && selected ? (
<PreviewPanel
selected={selected}
totalScoreBaseline={data.totalScore}
passScore={data.passScore}
/>
) : null
}
/>
);
}
/**
* 左栏:题库候选列表(带筛选)。
*/
function QuestionsLibraryPanel({
onAdd,
}: {
onAdd: (item: {
questionId: string;
content: string;
type: string;
difficulty: string;
score: number;
textbookName: string | null;
}) => void;
}): React.ReactElement {
const t = useTranslations("exams");
const [filter, setFilter] = useState<QuestionsLibraryFilter>({
q: "",
type: "",
difficulty: "",
page: 1,
pageSize: 10,
});
const { data, loading, error } = useQuestionsLibrary(filter);
const items = data?.items ?? [];
return (
<WorkbenchPanel title={t("build.libraryTitle")}>
<div className="space-y-3">
<Input
type="search"
placeholder={t("build.searchPlaceholder")}
value={filter.q ?? ""}
onChange={(e) =>
setFilter((prev) => ({ ...prev, q: e.target.value, page: 1 }))
}
/>
<div className="flex gap-2">
<select
className="h-9 rounded-md border bg-background px-3 text-sm"
value={filter.type ?? ""}
onChange={(e) =>
setFilter((prev) => ({
...prev,
type: e.target.value || null,
page: 1,
}))
}
aria-label={t("build.filterType")}
>
<option value="">{t("build.allTypes")}</option>
<option value="single_choice">{t("build.typeSingle")}</option>
<option value="multiple_choice">{t("build.typeMultiple")}</option>
<option value="fill_blank">{t("build.typeFill")}</option>
<option value="essay">{t("build.typeEssay")}</option>
</select>
<select
className="h-9 rounded-md border bg-background px-3 text-sm"
value={filter.difficulty ?? ""}
onChange={(e) =>
setFilter((prev) => ({
...prev,
difficulty: e.target.value || null,
page: 1,
}))
}
aria-label={t("build.filterDifficulty")}
>
<option value="">{t("build.allDifficulties")}</option>
<option value="easy">{t("build.diffEasy")}</option>
<option value="medium">{t("build.diffMedium")}</option>
<option value="hard">{t("build.diffHard")}</option>
</select>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">
{t("build.loadingLibrary")}
</p>
) : error ? (
<p className="text-sm text-destructive">
{t("build.loadLibraryFailed")}
</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("build.libraryEmpty")}
</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item.questionId}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<p className="text-sm">{item.content}</p>
<p className="text-xs text-muted-foreground">
<span className="mr-2">{item.type}</span>
<span className="mr-2">{item.difficulty}</span>
<span>
{item.score} {t("build.unitScore")}
</span>
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAdd(item)}
aria-label={t("build.addToExam")}
>
<Plus className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
{data ? (
<p className="text-xs text-muted-foreground">
{t("build.libraryTotal", { total: data.total })}
</p>
) : null}
</div>
</WorkbenchPanel>
);
}
/**
* 中栏:已选题目列表(可上移/下移/编辑分值/移除)。
*/
function SelectedQuestionsPanel({
selected,
onChange,
}: {
selected: ExamBuildNode[];
onChange: (next: ExamBuildNode[]) => void;
}): React.ReactElement {
const t = useTranslations("exams");
const moveUp = (index: number): void => {
if (index === 0) return;
const next = [...selected];
const a = next[index - 1];
const b = next[index];
if (!a || !b) return;
next[index - 1] = b;
next[index] = a;
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
};
const moveDown = (index: number): void => {
if (index === selected.length - 1) return;
const next = [...selected];
const a = next[index];
const b = next[index + 1];
if (!a || !b) return;
next[index] = b;
next[index + 1] = a;
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
};
const remove = (questionId: string): void => {
onChange(
selected
.filter((s) => s.questionId !== questionId)
.map((node, i) => ({ ...node, sortOrder: i + 1 })),
);
};
const updateScore = (questionId: string, score: number): void => {
onChange(
selected.map((s) => (s.questionId === questionId ? { ...s, score } : s)),
);
};
return (
<WorkbenchPanel
title={t("build.selectedTitle")}
actions={
<span className="text-xs text-muted-foreground">
{t("build.selectedCount", { count: selected.length })}
</span>
}
>
{selected.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("build.selectedEmpty")}
</p>
) : (
<ol className="space-y-2">
{selected.map((node, idx) => (
<li
key={node.questionId}
className="rounded-md border bg-background p-3"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-sm font-semibold text-muted-foreground">
{idx + 1}.
</span>
<div className="flex-1 space-y-2">
<p className="text-sm">{node.content}</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{node.type} · {node.difficulty}
</span>
<label className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">
{t("build.scoreLabel")}
</span>
<Input
type="number"
min={0}
value={node.score}
onChange={(e) =>
updateScore(
node.questionId,
Number(e.target.value) || 0,
)
}
className="h-7 w-16"
/>
</label>
</div>
</div>
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveUp(idx)}
disabled={idx === 0}
aria-label={t("build.moveUp")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveDown(idx)}
disabled={idx === selected.length - 1}
aria-label={t("build.moveDown")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(node.questionId)}
aria-label={t("build.remove")}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</li>
))}
</ol>
)}
</WorkbenchPanel>
);
}
/**
* 右栏:预览(总分、题型分布、及格分提示)。
*/
function PreviewPanel({
selected,
totalScoreBaseline,
passScore,
}: {
selected: ExamBuildNode[];
totalScoreBaseline: number;
passScore: number;
}): React.ReactElement {
const t = useTranslations("exams");
const total = sumSelectedScores(selected);
const typeCounts = countByType(selected);
const diff = total - totalScoreBaseline;
return (
<WorkbenchPanel title={t("build.previewTitle")}>
<div className="space-y-4">
<div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground">
{t("build.previewTotal")}
</p>
<p className="mt-1 text-3xl font-semibold">{total}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("build.previewBaseline", { baseline: totalScoreBaseline })}
{diff !== 0 ? (
<span
className={diff > 0 ? "text-amber-600" : "text-destructive"}
>
{" "}
({diff > 0 ? "+" : ""}
{diff})
</span>
) : null}
</p>
</div>
<div className="rounded-md border bg-background p-4">
<p className="text-xs text-muted-foreground">
{t("build.previewPassScore")}
</p>
<p className="mt-1 text-xl font-semibold">{passScore}</p>
</div>
<div className="rounded-md border bg-background p-4">
<p className="mb-2 text-xs text-muted-foreground">
{t("build.previewTypeBreakdown")}
</p>
{Object.keys(typeCounts).length === 0 ? (
<p className="text-sm text-muted-foreground">--</p>
) : (
<ul className="space-y-1 text-sm">
{Object.entries(typeCounts).map(([type, count]) => (
<li key={type} className="flex justify-between">
<span className="text-muted-foreground">{type}</span>
<span>{count}</span>
</li>
))}
</ul>
)}
</div>
</div>
</WorkbenchPanel>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
/**
* 考试详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 exam(id: ID!):✅ schema 真实字段core-edu 子图)
* - 无需 MSW 兜底,但 MSW 开启时也会返回 mock 数据dev 体验)
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
*/
import { FileText } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useExam, type Exam } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
formatExamDate,
formatExamStatus,
formatDuration,
isExamEditable,
parseTotalScore,
} from "@/features/teacher/exams/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ExamDetailClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
// ✅ 真实查询exam(id: ID!)schema 已就绪
const { data, loading, error } = useExam(examId);
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={data?.title ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatExamDate(data.createdAt),
})
: undefined
}
icon={<FileText className="size-6" />}
backHref="/shell/teacher/exams"
actions={
data && isExamEditable(data.status) ? (
<Button variant="outline">{t("detail.edit")}</Button>
) : null
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("detail.notFound")}
</div>
) : undefined
}
>
{data ? <ExamDetailBody exam={data} /> : null}
</DetailPageShell>
);
}
/**
* 详情内容区(基本信息 + 状态信息两个分区)。
*/
function ExamDetailBody({ exam }: { exam: Exam }): React.ReactElement {
const t = useTranslations("exams");
return (
<>
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldTitle")} value={exam.title} />
<DetailField
label={t("detail.fieldDescription")}
value={exam.description ?? "-"}
/>
<DetailField
label={t("detail.fieldExamDate")}
value={formatExamDate(exam.examDate)}
/>
<DetailField
label={t("detail.fieldDuration")}
value={formatDuration(exam.duration)}
/>
<DetailField
label={t("detail.fieldTotalScore")}
value={`${parseTotalScore(exam.totalScore)} ${t("detail.unitScore")}`}
/>
<DetailField label={t("detail.fieldClassId")} value={exam.classId} />
<DetailField
label={t("detail.fieldSubjectId")}
value={exam.subjectId}
/>
</DetailSection>
<DetailSection title={t("detail.sectionStatus")}>
<DetailField
label={t("detail.fieldCurrentStatus")}
value={formatExamStatus(exam.status)}
/>
<DetailField
label={t("detail.fieldStatusChangedAt")}
value={formatExamDate(exam.statusChangedAt)}
/>
<DetailField
label={t("detail.fieldStatusChangedBy")}
value={exam.statusChangedBy ?? "-"}
/>
<DetailField
label={t("detail.fieldCreatedBy")}
value={exam.createdBy}
/>
<DetailField
label={t("detail.fieldCreatedAt")}
value={formatExamDate(exam.createdAt)}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatExamDate(exam.updatedAt)}
/>
</DetailSection>
</>
);
}

View File

@@ -0,0 +1,314 @@
"use client";
/**
* 富文本试卷编辑页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约(@contract-pending 全 MSW
* - examRichEditor(examId) ❌ → MSW 兜底
* - saveExamRichContent(input) mutation ❌ → MSW 兜底
*
* 布局WorkbenchPageShell 单 center 栏):
* - center富文本编辑器contentEditable + 工具栏)
* - 工具栏:加粗/斜体/标题 H1/H2/H3/列表/插入题目占位
* - 内容区:可编辑 div
* - 底部:保存/预览按钮
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { forwardRef, useEffect, useRef, useState } from "react";
import { useExamRichEditor, useSaveExamRichContent } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
WorkbenchPageShell,
WorkbenchPageSkeleton,
WorkbenchPanel,
} from "@/shared/components/page-templates";
/**
* 富文本编辑客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ExamEditClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const examId = params?.id ?? "";
// @contract-pending MSW 兜底
const { data, loading, error } = useExamRichEditor(examId);
const saveMutation = useSaveExamRichContent();
// 本地 HTML 内容(从 data.content 初始化)
const [html, setHtml] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
// 首次拿到数据时初始化 HTML 内容
useEffect(() => {
if (data && html === null) {
setHtml(contentToHtml(data.content));
}
}, [data, html]);
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
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>
) : !loading && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("edit.notFound")}
</div>
) : undefined;
const handleSave = async (): Promise<void> => {
if (!data) return;
const currentHtml = editorRef.current?.innerHTML ?? html ?? "";
try {
await saveMutation.run({ examId, content: currentHtml });
notify.success(t("edit.saveSuccess"));
} catch (err) {
notify.error(t("edit.saveFailed", { message: String(err) }));
}
};
const exec = (command: string, value?: string): void => {
if (!editorRef.current) return;
editorRef.current.focus();
// document.execCommand 已 deprecated 但 contentEditable 简单方案仍可用
// 后续可换 tiptap/lexical@contract-pending 富文本库)
document.execCommand(command, false, value);
setHtml(editorRef.current.innerHTML);
};
return (
<WorkbenchPageShell
title={data?.title ?? t("edit.title")}
description={
data
? t("edit.subtitle", {
total: data.totalScore,
count: data.questionCount,
})
: undefined
}
icon={<FileText className="size-6" />}
actions={
<Button
type="button"
disabled={saveMutation.loading || !data}
onClick={handleSave}
>
{saveMutation.loading ? tCommon("status.loading") : t("edit.save")}
</Button>
}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode}
center={
data && html !== null ? (
<RichEditor
ref={editorRef}
html={html}
onChange={setHtml}
onExec={exec}
/>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("edit.propertiesTitle")}>
<div className="space-y-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propExamId")}
</p>
<p className="mt-1 font-mono text-xs">{data.examId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propTotalScore")}
</p>
<p className="mt-1 font-semibold">{data.totalScore}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propQuestionCount")}
</p>
<p className="mt-1 font-semibold">{data.questionCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("edit.propUpdatedAt")}
</p>
<p className="mt-1 text-xs">{data.updatedAt}</p>
</div>
<p className="text-xs text-muted-foreground">
{t("edit.contractPending")}
</p>
</div>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 富文本编辑器contentEditable + 工具栏)。
*
* 简化实现:使用 document.execCommanddeprecated 但仍可用)。
* 后续可升级到 tiptap/lexical@contract-pending 富文本库契约)。
*/
interface RichEditorProps {
html: string;
onChange: (html: string) => void;
onExec: (command: string, value?: string) => void;
}
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
function RichEditor({ html, onChange, onExec }, ref) {
const t = useTranslations("exams");
return (
<WorkbenchPanel title={t("edit.editorTitle")}>
<div className="flex h-full flex-col gap-3">
{/* 工具栏 */}
<div className="flex flex-wrap gap-1 border-b pb-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("bold")}
aria-label={t("edit.toolbarBold")}
>
<strong>B</strong>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("italic")}
aria-label={t("edit.toolbarItalic")}
>
<em>I</em>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("underline")}
aria-label={t("edit.toolbarUnderline")}
>
<u>U</u>
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h1>")}
>
H1
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h2>")}
>
H2
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<h3>")}
>
H3
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertUnorderedList")}
aria-label={t("edit.toolbarBulletList")}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("insertOrderedList")}
aria-label={t("edit.toolbarOrderedList")}
>
1.
</Button>
<span className="mx-1 border-l" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onExec("formatBlock", "<p>")}
>
P
</Button>
</div>
{/* 可编辑区域 */}
<div
ref={ref}
contentEditable
suppressContentEditableWarning
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
dangerouslySetInnerHTML={{ __html: html }}
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
style={{ minHeight: "400px" }}
/>
</div>
</WorkbenchPanel>
);
},
);
/**
* 将 mock data.content结构化数组转换为 HTML 字符串。
* 输入是 unknown来自 MSW输出是可编辑的 HTML。
*/
function contentToHtml(content: unknown): string {
if (!Array.isArray(content)) return "";
return content
.map((node: Record<string, unknown>) => {
const type = node.type as string;
const text = (node.text as string) ?? "";
switch (type) {
case "heading": {
const level = node.level as number;
const tag = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
return `<${tag}>${text}</${tag}>`;
}
case "paragraph":
return `<p>${text}</p>`;
case "question": {
const order = node.order as number;
const options = (node.options as string[]) ?? [];
const optsHtml = options.map((opt) => `<div>${opt}</div>`).join("");
return `<div><strong>${order}. ${text}</strong>${optsHtml}</div>`;
}
default:
return `<div>${text}</div>`;
}
})
.join("");
}

View File

@@ -0,0 +1,226 @@
"use client";
/**
* 考试管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 exams(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#exams-list
*
* URL 状态:?classId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useExams, type ExamListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatExamDate,
formatExamStatus,
formatDuration,
parseTotalScore,
} from "@/features/teacher/exams/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function ExamsListClient(): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "cls-001";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useExams(classId);
// 客户端二次筛选status + q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<ExamListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (statusFilter && item.status !== statusFilter) return false;
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
return false;
}
return true;
});
}, [data, 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/teacher/exams?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{/* @contract-pending 提示MSW 兜底时若未开启 NEXT_PUBLIC_MSW=1 会失败 */}
{t("list.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<FileText className="size-6" />}
actions={
<Button asChild>
<Link href={`/shell/teacher/exams/new?classId=${classId}`}>
{t("list.new")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
<option value="SCORED">{t("list.statusScored")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<ExamsTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ExamsTable({ items }: { items: ExamListItem[] }): React.ReactElement {
const t = useTranslations("exams");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colExamDate")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colDuration")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colTotalScore")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((exam) => (
<tr key={exam.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/exams/${exam.id}`}
className="font-medium hover:underline"
>
{exam.title}
</Link>
{exam.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{exam.description}
</p>
) : null}
</td>
<td className="p-3">
<ExamStatusBadge status={exam.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatExamDate(exam.examDate)}
</td>
<td className="p-3 text-xs">{formatDuration(exam.duration)}</td>
<td className="p-3">
{parseTotalScore(exam.totalScore)} {t("list.unitScore")}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/exams/${exam.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 考试状态徽章(按状态色阶展示)。
*/
function ExamStatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatExamStatus(status);
const cls =
status === "DRAFT"
? "bg-muted text-muted-foreground"
: status === "PUBLISHED" || status === "IN_PROGRESS"
? "bg-primary/10 text-primary"
: status === "SCORED"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground";
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
/**
* 新建考试表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - mutation createExam(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-exam-mutation
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import { useCreateExam, type CreateExamInput } from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function NewExamClient(): React.ReactElement {
const t = useTranslations("exams");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const presetClassId = searchParams.get("classId") ?? "";
// @contract-pendingMSW 兜底
const { run: createExam, loading: submitting } = useCreateExam();
const handleSubmit = async (input: CreateExamInput): Promise<void> => {
try {
const result = await createExam(input);
notify.success(t("new.success"));
startTransition(() => {
router.push(`/shell/teacher/exams?classId=${input.classId}`);
});
void result;
} catch (err) {
notify.error(`${t("new.error")}: ${String(err)}`);
}
};
return (
<NewExamFormInner
presetClassId={presetClassId}
submitting={submitting}
onSubmit={handleSubmit}
/>
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*
* 注:未引入 react-hook-form + zod因当前仅一个表单引入会增加依赖。
* 后续表单数量增多后统一迁移到 react-hook-form§7.3 表单页模板建议)。
*/
function NewExamFormInner({
presetClassId,
submitting,
onSubmit,
}: {
presetClassId: string;
submitting: boolean;
onSubmit: (input: CreateExamInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("exams");
const tCommon = useTranslations("common");
const [classId, setClassId] = useState(presetClassId);
const [subjectId, setSubjectId] = useState("sub-math");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [examDate, setExamDate] = useState("");
const [duration, setDuration] = useState("120");
const [totalScore, setTotalScore] = useState("100");
const [error, setError] = useState<string | null>(null);
const handleFormSubmit = (): void => {
setError(null);
if (!classId.trim()) {
setError(t("new.errorClassRequired"));
return;
}
if (!title.trim()) {
setError(t("new.errorTitleRequired"));
return;
}
if (!examDate) {
setError(t("new.errorDateRequired"));
return;
}
const input: CreateExamInput = {
classId: classId.trim(),
subjectId: subjectId.trim(),
title: title.trim(),
description: description.trim() || undefined,
examDate: new Date(examDate).toISOString(),
duration: Number(duration) || 0,
totalScore: Number(totalScore) || 0,
};
void onSubmit(input);
};
return (
<FormPageShell
title={t("new.title")}
description={t("new.description")}
icon={<FileText className="size-6" />}
backHref={`/shell/teacher/exams?classId=${classId}`}
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("new.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
{/* 班级 ID */}
<FormField label={t("new.classId")} required>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="cls-001"
required
/>
</FormField>
{/* 科目 ID */}
<FormField label={t("new.subjectId")} required>
<input
type="text"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="sub-math"
required
/>
</FormField>
{/* 标题 */}
<FormField label={t("new.titleLabel")} required>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("new.titlePlaceholder")}
required
/>
</FormField>
{/* 描述 */}
<FormField label={t("new.descriptionLabel")}>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.descriptionPlaceholder")}
/>
</FormField>
{/* 考试日期 */}
<FormField label={t("new.examDate")} required>
<input
type="datetime-local"
value={examDate}
onChange={(e) => setExamDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
{/* 时长 + 满分 */}
<div className="grid grid-cols-2 gap-4">
<FormField label={t("new.duration")} required>
<input
type="number"
min={1}
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("new.totalScore")} required>
<input
type="number"
min={1}
value={totalScore}
onChange={(e) => setTotalScore(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
</div>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">
{t("new.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
*/
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>
);
}

View File

@@ -0,0 +1,218 @@
/**
* Exams 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Exam, ExamListItem } from "@/lib/api";
/** 考试状态中文标签映射(对齐旧 teacher-portal EXAM_STATUS_LABEL */
export const EXAM_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
IN_PROGRESS: "进行中",
GRADING: "批改中",
SCORED: "已完成",
ARCHIVED: "已归档",
};
/**
* 将考试状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatExamStatus(status: string): string {
return EXAM_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN
* 输入无效时返回占位符。
*/
export function formatExamDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 将 schema 的 totalScoreString转为数值用于展示。
* 转换失败返回 0。
*/
export function parseTotalScore(totalScore: string | number): number {
const n = typeof totalScore === "number" ? totalScore : Number(totalScore);
return Number.isFinite(n) ? n : 0;
}
/**
* 判断考试是否处于可编辑状态DRAFT
*/
export function isExamEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断考试是否处于已发布后的状态(不可再编辑)。
*/
export function isExamPublished(status: string): boolean {
return (
status === "PUBLISHED" ||
status === "IN_PROGRESS" ||
status === "GRADING" ||
status === "SCORED" ||
status === "ARCHIVED"
);
}
/**
* 从考试详情中提取列表项视图模型(裁剪字段)。
*/
export function toExamListItem(exam: Exam): ExamListItem {
return {
id: exam.id,
classId: exam.classId,
subjectId: exam.subjectId,
title: exam.title,
description: exam.description,
examDate: exam.examDate,
duration: exam.duration,
totalScore: exam.totalScore,
status: exam.status,
createdAt: exam.createdAt,
};
}
/**
* 格式化时长(分钟)为更友好的展示。
* - 60 分钟以下:返回 "N 分钟"
* - 60 分钟以上:返回 "X 小时 Y 分钟"
*/
export function formatDuration(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return "--";
if (minutes < 60) return `${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
}
// ── Analytics 纯函数ARCHITECTURE.md §9.1 analytics 页 / §11.3 DoD──
/**
* 格式化分数(数值)为展示字符串,保留 1 位小数。
* 输入无效返回 "--"。
*/
export function formatScore(score: number): string {
if (!Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
* 输入无效返回 "--"。
*/
export function formatPassRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 将 0-1 的小数(如 0.92)格式化为百分比字符串 "92%"。
* 与 formatPassRate 同义,语义区分:用于正确率/通过率等不同场景。
*/
export function formatPercent(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 根据正确率返回等级标签A/B/C/D
* - rate >= 0.85 → A
* - rate >= 0.7 → B
* - rate >= 0.6 → C
* - 其他 → D
*/
export function rateToLevel(rate: number): string {
if (!Number.isFinite(rate)) return "--";
if (rate >= 0.85) return "A";
if (rate >= 0.7) return "B";
if (rate >= 0.6) return "C";
return "D";
}
/**
* 根据正确率返回 Tailwind 文本语义类名。
* - >= 0.8 → text-emerald-600
* - >= 0.6 → text-amber-600
* - 其他 → text-destructive
*/
export function rateToColorClass(rate: number): string {
if (!Number.isFinite(rate)) return "text-muted-foreground";
if (rate >= 0.8) return "text-emerald-600";
if (rate >= 0.6) return "text-amber-600";
return "text-destructive";
}
/**
* 根据等级A/B/C/D返回 Tailwind 文本语义类名。
*/
export function levelToColorClass(level: string): string {
switch (level) {
case "A":
return "text-emerald-600";
case "B":
return "text-blue-600";
case "C":
return "text-amber-600";
case "D":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
// ── Build 纯函数ARCHITECTURE.md §9.1 build 工作台页 / §11.3 DoD──
/**
* 计算已选题目总分。
*/
export function sumSelectedScores(items: Array<{ score: number }>): number {
return items.reduce((sum, item) => sum + item.score, 0);
}
/**
* 按题量统计题型分布(如 { single_choice: 5, multiple_choice: 3 })。
*/
export function countByType(
items: Array<{ type: string }>,
): Record<string, number> {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.type] = (acc[item.type] ?? 0) + 1;
return acc;
}, {});
}
/**
* 按 sortOrder 升序排序已选题目(返回新数组,不修改原数组)。
*/
export function sortBySortOrder<T extends { sortOrder: number }>(
items: ReadonlyArray<T>,
): T[] {
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
}
/**
* 生成下一个 sortOrder当前最大值 + 1
*/
export function nextSortOrder(
items: ReadonlyArray<{ sortOrder: number }>,
): number {
if (items.length === 0) return 1;
return Math.max(...items.map((i) => i.sortOrder)) + 1;
}

View File

@@ -0,0 +1,318 @@
/**
* Grades 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Grade } from "@/lib/api";
import {
GRADE_LEVEL_LABEL,
GRADE_STATUS_LABEL,
calcGradeDistribution,
formatGradeDate,
formatGradeLevel,
formatGradeStatus,
formatPassRate,
formatPercent,
formatScore,
isPassing,
levelToColorClass,
parseScore,
scoreRateToColorClass,
scoreToLevel,
toGradeListItem,
} from "../transformations";
describe("formatGradeStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatGradeStatus("GRADED")).toBe("已评分");
expect(formatGradeStatus("PENDING")).toBe("待评分");
expect(formatGradeStatus("EXEMPT")).toBe("免考");
expect(formatGradeStatus("ABSENT")).toBe("缺考");
});
it("returns original value for unknown status", () => {
expect(formatGradeStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatGradeStatus("")).toBe("");
});
it("GRADE_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(GRADE_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatGradeLevel", () => {
it("maps known levels to Chinese labels", () => {
expect(formatGradeLevel("A")).toBe("优秀");
expect(formatGradeLevel("B")).toBe("良好");
expect(formatGradeLevel("C")).toBe("合格");
expect(formatGradeLevel("D")).toBe("不合格");
});
it("returns original value for unknown level", () => {
expect(formatGradeLevel("X")).toBe("X");
expect(formatGradeLevel("")).toBe("");
});
it("GRADE_LEVEL_LABEL covers A/B/C/D", () => {
expect(Object.keys(GRADE_LEVEL_LABEL)).toHaveLength(4);
});
});
describe("formatGradeDate", () => {
it("formats valid ISO date string", () => {
const result = formatGradeDate("2026-07-22T10:00:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatGradeDate(null)).toBe("--");
expect(formatGradeDate(undefined)).toBe("--");
expect(formatGradeDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatGradeDate("not-a-date")).toBe("--");
});
});
describe("parseScore", () => {
it("parses numeric string", () => {
expect(parseScore("95")).toBe(95);
expect(parseScore("0")).toBe(0);
});
it("passes through number input", () => {
expect(parseScore(88)).toBe(88);
});
it("returns 0 for non-numeric string", () => {
expect(parseScore("abc")).toBe(0);
});
it("returns 0 for NaN", () => {
expect(parseScore(Number.NaN)).toBe(0);
});
});
describe("formatScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatScore(82.5)).toBe("82.5");
expect(formatScore(98)).toBe("98.0");
expect(formatScore(0)).toBe("0.0");
});
it("returns placeholder for non-finite input", () => {
expect(formatScore(Number.NaN)).toBe("--");
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
});
});
describe("formatPassRate / formatPercent", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatPassRate(0.86)).toBe("86%");
expect(formatPassRate(0)).toBe("0%");
expect(formatPassRate(1)).toBe("100%");
expect(formatPercent(0.92)).toBe("92%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatPassRate(-0.1)).toBe("--");
expect(formatPassRate(1.1)).toBe("--");
expect(formatPassRate(Number.NaN)).toBe("--");
expect(formatPercent(Number.NaN)).toBe("--");
});
});
describe("scoreToLevel", () => {
it("maps score ratio to A/B/C/D levels", () => {
expect(scoreToLevel(90, 100)).toBe("A");
expect(scoreToLevel(85, 100)).toBe("A");
expect(scoreToLevel(75, 100)).toBe("B");
expect(scoreToLevel(70, 100)).toBe("B");
expect(scoreToLevel(65, 100)).toBe("C");
expect(scoreToLevel(60, 100)).toBe("C");
expect(scoreToLevel(59, 100)).toBe("D");
expect(scoreToLevel(0, 100)).toBe("D");
});
it("returns placeholder for invalid input", () => {
expect(scoreToLevel(Number.NaN, 100)).toBe("--");
expect(scoreToLevel(80, 0)).toBe("--");
expect(scoreToLevel(80, -1)).toBe("--");
});
});
describe("isPassing", () => {
it("returns true for score >= 60% of totalScore by default", () => {
expect(isPassing(60, 100)).toBe(true);
expect(isPassing(59, 100)).toBe(false);
expect(isPassing(80, 100)).toBe(true);
});
it("respects custom passScore", () => {
expect(isPassing(50, 100, 50)).toBe(true);
expect(isPassing(49, 100, 50)).toBe(false);
});
it("returns false for invalid input", () => {
expect(isPassing(Number.NaN, 100)).toBe(false);
expect(isPassing(80, 0)).toBe(false);
expect(isPassing(80, -1)).toBe(false);
});
});
describe("scoreRateToColorClass", () => {
it("returns emerald for high rates (>= 0.8)", () => {
expect(scoreRateToColorClass(80, 100)).toBe("text-emerald-600");
expect(scoreRateToColorClass(95, 100)).toBe("text-emerald-600");
});
it("returns amber for medium rates (>= 0.6)", () => {
expect(scoreRateToColorClass(60, 100)).toBe("text-amber-600");
expect(scoreRateToColorClass(79, 100)).toBe("text-amber-600");
});
it("returns destructive for low rates (< 0.6)", () => {
expect(scoreRateToColorClass(59, 100)).toBe("text-destructive");
expect(scoreRateToColorClass(0, 100)).toBe("text-destructive");
});
it("returns muted for invalid input", () => {
expect(scoreRateToColorClass(Number.NaN, 100)).toBe(
"text-muted-foreground",
);
expect(scoreRateToColorClass(80, 0)).toBe("text-muted-foreground");
});
});
describe("levelToColorClass", () => {
it("maps each level to correct color class", () => {
expect(levelToColorClass("A")).toBe("text-emerald-600");
expect(levelToColorClass("B")).toBe("text-blue-600");
expect(levelToColorClass("C")).toBe("text-amber-600");
expect(levelToColorClass("D")).toBe("text-destructive");
});
it("returns muted for unknown level", () => {
expect(levelToColorClass("X")).toBe("text-muted-foreground");
expect(levelToColorClass("")).toBe("text-muted-foreground");
});
});
describe("calcGradeDistribution", () => {
it("distributes scores into 5 buckets", () => {
const scores = [
{ score: 95, totalScore: 100 },
{ score: 85, totalScore: 100 },
{ score: 75, totalScore: 100 },
{ score: 65, totalScore: 100 },
{ score: 45, totalScore: 100 },
];
const result = calcGradeDistribution(scores);
expect(result).toHaveLength(5);
expect(result[0]!.count).toBe(1); // 0-59
expect(result[1]!.count).toBe(1); // 60-69
expect(result[2]!.count).toBe(1); // 70-79
expect(result[3]!.count).toBe(1); // 80-89
expect(result[4]!.count).toBe(1); // 90-100
});
it("handles empty array", () => {
const result = calcGradeDistribution([]);
expect(result).toHaveLength(5);
expect(result.every((b) => b.count === 0)).toBe(true);
});
it("skips invalid scores", () => {
const scores = [
{ score: 90, totalScore: 100 },
{ score: Number.NaN, totalScore: 100 },
{ score: 80, totalScore: 0 },
];
const result = calcGradeDistribution(scores);
expect(result[4]!.count).toBe(1); // only 90/100 counted
expect(result[3]!.count).toBe(0);
});
it("handles boundary values correctly", () => {
const scores = [
{ score: 60, totalScore: 100 }, // 60-69
{ score: 70, totalScore: 100 }, // 70-79
{ score: 80, totalScore: 100 }, // 80-89
{ score: 90, totalScore: 100 }, // 90-100
{ score: 59, totalScore: 100 }, // 0-59
];
const result = calcGradeDistribution(scores);
expect(result[0]!.count).toBe(1); // 59 → 0-59
expect(result[1]!.count).toBe(1); // 60 → 60-69
expect(result[2]!.count).toBe(1); // 70 → 70-79
expect(result[3]!.count).toBe(1); // 80 → 80-89
expect(result[4]!.count).toBe(1); // 90 → 90-100
});
});
describe("toGradeListItem", () => {
it("extracts list fields from full grade with extra info", () => {
const grade: Grade = {
id: "grade-001",
studentId: "stu-001",
examId: "exam-001",
homeworkId: null,
score: "95",
totalScore: "100",
feedback: "基础扎实",
gradedBy: "usr-teacher-001",
schoolId: "sch-001",
idempotencyKey: null,
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
};
const item = toGradeListItem(grade, {
studentName: "张明",
studentNo: "2026001",
classId: "cls-001",
className: "高三(1)班",
examName: "期中考试",
status: "GRADED",
});
expect(item.id).toBe("grade-001");
expect(item.studentName).toBe("张明");
expect(item.score).toBe(95); // string → number
expect(item.totalScore).toBe(100);
expect(item.examName).toBe("期中考试");
expect(item.status).toBe("GRADED");
});
it("handles missing extra info with defaults", () => {
const grade: Grade = {
id: "grade-002",
studentId: "stu-002",
examId: null,
homeworkId: "hw-001",
score: "88",
totalScore: "100",
feedback: null,
gradedBy: "usr-teacher-001",
schoolId: "sch-001",
idempotencyKey: null,
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toGradeListItem(grade);
expect(item.studentName).toBe("--");
expect(item.studentNo).toBe("--");
expect(item.className).toBe("--");
expect(item.examName).toBeNull();
expect(item.homeworkTitle).toBeNull();
expect(item.status).toBe("GRADED");
expect(item.score).toBe(88);
});
});

View File

@@ -0,0 +1,246 @@
"use client";
/**
* 成绩分析页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(混合契约):
* - 基础统计 ✅ assignmentAnalysisdata-ana 子图schema 已就绪)
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useGradeAnalytics } from "@/lib/api";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
} from "@/shared/components/page-templates";
import {
formatPassRate,
formatScore,
levelToColorClass,
} from "@/features/teacher/grades/transformations";
/**
* 分析客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function GradeAnalyticsClient(): React.ReactElement {
const t = useTranslations("grades");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const examId = searchParams.get("examId") ?? "exam-001";
const classId = searchParams.get("classId") ?? undefined;
// 混合契约:基础统计 + 扩展字段 MSW
const { data, loading, error } = useGradeAnalytics({ examId, classId });
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={data?.scopeName ?? t("analytics.title")}
description={
data ? t("analytics.subtitle", { scope: data.scope }) : undefined
}
icon={<BarChart3 className="size-6" />}
backHref="/shell/teacher/grades"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("analytics.notFound")}
</div>
) : undefined
}
>
{data ? <GradeAnalyticsBody data={data} /> : null}
</DetailPageShell>
);
}
/**
* 分析内容区(汇总卡片 + 分布图 + 学生排名)。
*/
function GradeAnalyticsBody({
data,
}: {
data: NonNullable<ReturnType<typeof useGradeAnalytics>["data"]>;
}): React.ReactElement {
const t = useTranslations("grades");
return (
<>
<DetailSection title={t("analytics.sectionSummary")}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<SummaryCard
label={t("analytics.summaryTotal")}
value={String(data.summary.totalStudents)}
/>
<SummaryCard
label={t("analytics.summaryGraded")}
value={String(data.summary.gradedCount)}
/>
<SummaryCard
label={t("analytics.summaryAvg")}
value={formatScore(data.summary.avgScore)}
/>
<SummaryCard
label={t("analytics.summaryMax")}
value={formatScore(data.summary.maxScore)}
/>
<SummaryCard
label={t("analytics.summaryMin")}
value={formatScore(data.summary.minScore)}
/>
<SummaryCard
label={t("analytics.summaryPassRate")}
value={formatPassRate(data.summary.passRate)}
/>
<SummaryCard
label={t("analytics.summaryPassScore")}
value={formatScore(data.summary.passScore)}
/>
</div>
</DetailSection>
<DetailSection title={t("analytics.sectionDistribution")}>
<DistributionChart items={data.distribution} />
</DetailSection>
<DetailSection title={t("analytics.sectionRankings")}>
<RankingsTable items={data.rankings} />
</DetailSection>
</>
);
}
/**
* 汇总卡片。
*/
function SummaryCard({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold">{value}</p>
</div>
);
}
/**
* 分数段分布柱状图(纯 SVG
*/
function DistributionChart({
items,
}: {
items: Array<{ label: string; count: number }>;
}): React.ReactElement {
const t = useTranslations("grades");
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyDistribution")}
</p>
);
}
const maxCount = Math.max(...items.map((i) => i.count), 1);
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-3">
<span className="w-16 shrink-0 text-sm text-muted-foreground">
{item.label}
</span>
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
</div>
))}
</div>
);
}
/**
* 学生排名表格。
*/
function RankingsTable({
items,
}: {
items: Array<{
studentId: string;
studentNo: string;
studentName: string;
score: number;
rank: number;
level: string;
}>;
}): React.ReactElement {
const t = useTranslations("grades");
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("analytics.emptyRankings")}
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colStudentNo")}
</th>
<th className="py-2 pr-4 font-medium">
{t("analytics.colStudentName")}
</th>
<th className="py-2 pr-4 font-medium">{t("analytics.colScore")}</th>
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.studentId} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{item.rank}</td>
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
<td className="py-2 pr-4">{item.studentName}</td>
<td className="py-2 pr-4">{formatScore(item.score)}</td>
<td className="py-2 pr-4">
<span className={levelToColorClass(item.level)}>
{item.level}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
/**
* 成绩录入表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - mutation createGrade(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Award } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import { useCreateGrade, type CreateGradeInput } from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function GradeEntryClient(): React.ReactElement {
const t = useTranslations("grades");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const presetClassId = searchParams.get("classId") ?? "";
// @contract-pendingMSW 兜底
const { run: createGrade, loading: submitting } = useCreateGrade();
const handleSubmit = async (input: CreateGradeInput): Promise<void> => {
try {
const result = await createGrade(input);
notify.success(t("entry.success"));
startTransition(() => {
router.push(`/shell/teacher/grades?classId=${input.classId}`);
});
void result;
} catch (err) {
notify.error(`${t("entry.error")}: ${String(err)}`);
}
};
return (
<GradeEntryFormInner
presetClassId={presetClassId}
submitting={submitting}
onSubmit={handleSubmit}
/>
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*/
function GradeEntryFormInner({
presetClassId,
submitting,
onSubmit,
}: {
presetClassId: string;
submitting: boolean;
onSubmit: (input: CreateGradeInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("grades");
const tCommon = useTranslations("common");
const [classId, setClassId] = useState(presetClassId);
const [studentId, setStudentId] = useState("");
const [examId, setExamId] = useState("");
const [homeworkId, setHomeworkId] = useState("");
const [score, setScore] = useState("");
const [totalScore, setTotalScore] = useState("100");
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const handleFormSubmit = (): void => {
setError(null);
if (!classId.trim()) {
setError(t("entry.errorClassRequired"));
return;
}
if (!studentId.trim()) {
setError(t("entry.errorStudentRequired"));
return;
}
const scoreNum = Number(score);
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
setError(t("entry.errorScoreInvalid"));
return;
}
const totalNum = Number(totalScore);
if (!Number.isFinite(totalNum) || totalNum <= 0) {
setError(t("entry.errorTotalInvalid"));
return;
}
if (!examId.trim() && !homeworkId.trim()) {
setError(t("entry.errorSourceRequired"));
return;
}
const input: CreateGradeInput = {
classId: classId.trim(),
studentId: studentId.trim(),
examId: examId.trim() || undefined,
homeworkId: homeworkId.trim() || undefined,
score: scoreNum,
totalScore: totalNum,
feedback: feedback.trim() || undefined,
};
void onSubmit(input);
};
return (
<FormPageShell
title={t("entry.title")}
description={t("entry.description")}
icon={<Award className="size-6" />}
backHref={`/shell/teacher/grades?classId=${classId}`}
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("entry.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
{/* 班级 ID */}
<FormField label={t("entry.classId")} required>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="cls-001"
required
/>
</FormField>
{/* 学生 ID */}
<FormField label={t("entry.studentId")} required>
<input
type="text"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="stu-001"
required
/>
</FormField>
{/* 考试 ID可选 */}
<FormField label={t("entry.examId")}>
<input
type="text"
value={examId}
onChange={(e) => setExamId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("entry.examIdPlaceholder")}
/>
</FormField>
{/* 作业 ID可选 */}
<FormField label={t("entry.homeworkId")}>
<input
type="text"
value={homeworkId}
onChange={(e) => setHomeworkId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("entry.homeworkIdPlaceholder")}
/>
</FormField>
{/* 分数 + 满分 */}
<div className="grid grid-cols-2 gap-4">
<FormField label={t("entry.score")} required>
<input
type="number"
min={0}
value={score}
onChange={(e) => setScore(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("entry.totalScore")} required>
<input
type="number"
min={1}
value={totalScore}
onChange={(e) => setTotalScore(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
</div>
{/* 反馈 */}
<FormField label={t("entry.feedbackLabel")}>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("entry.feedbackPlaceholder")}
/>
</FormField>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">
{t("entry.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器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>
);
}

View File

@@ -0,0 +1,218 @@
"use client";
/**
* 成绩统计页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - gradeStats(classId, period):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#grade-stats
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useGradeStats, type GradeStatsSummary } from "@/lib/api";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
} from "@/shared/components/page-templates";
import {
formatPassRate,
formatScore,
scoreRateToColorClass,
} from "@/features/teacher/grades/transformations";
/**
* 统计客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function GradeStatsClient(): React.ReactElement {
const t = useTranslations("grades");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "cls-001";
const period = searchParams.get("period") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useGradeStats(classId, period);
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={t("stats.title")}
description={
data
? t("stats.subtitle", { classId: data.classId, period: data.period })
: undefined
}
icon={<BarChart3 className="size-6" />}
backHref="/shell/teacher/grades"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("stats.notFound")}
</div>
) : undefined
}
>
{data ? <GradeStatsBody summaries={data.summaries} /> : null}
</DetailPageShell>
);
}
/**
* 统计内容区(学科维度汇总卡片 + 表格)。
*/
function GradeStatsBody({
summaries,
}: {
summaries: GradeStatsSummary[];
}): React.ReactElement {
const t = useTranslations("grades");
if (summaries.length === 0) {
return (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("stats.empty")}
</div>
);
}
return (
<>
<DetailSection title={t("stats.sectionSummary")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summaries.map((s) => (
<SubjectSummaryCard
key={`${s.classId}-${s.subjectId}`}
summary={s}
/>
))}
</div>
</DetailSection>
<DetailSection title={t("stats.sectionTable")}>
<StatsTable summaries={summaries} />
</DetailSection>
</>
);
}
/**
* 学科维度统计卡片。
*/
function SubjectSummaryCard({
summary,
}: {
summary: GradeStatsSummary;
}): React.ReactElement {
const t = useTranslations("grades");
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<p className="font-semibold">{summary.subjectName}</p>
<span className="text-xs text-muted-foreground">
{t("stats.studentCount", { count: summary.studentCount })}
</span>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">{t("stats.avgScore")}</dt>
<dd className="text-right font-medium">
{formatScore(summary.avgScore)}
</dd>
<dt className="text-muted-foreground">{t("stats.maxScore")}</dt>
<dd className="text-right font-medium">
{formatScore(summary.maxScore)}
</dd>
<dt className="text-muted-foreground">{t("stats.minScore")}</dt>
<dd className="text-right font-medium">
{formatScore(summary.minScore)}
</dd>
<dt className="text-muted-foreground">{t("stats.passRate")}</dt>
<dd
className={`text-right font-medium ${scoreRateToColorClass(summary.passRate, 1)}`}
>
{formatPassRate(summary.passRate)}
</dd>
<dt className="text-muted-foreground">{t("stats.passCount")}</dt>
<dd className="text-right font-medium">{summary.passCount}</dd>
<dt className="text-muted-foreground">{t("stats.failCount")}</dt>
<dd className="text-right font-medium text-destructive">
{summary.failCount}
</dd>
</dl>
</div>
);
}
/**
* 学科维度统计表格。
*/
function StatsTable({
summaries,
}: {
summaries: GradeStatsSummary[];
}): React.ReactElement {
const t = useTranslations("grades");
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">{t("stats.colSubject")}</th>
<th className="py-2 pr-4 font-medium">{t("stats.colClass")}</th>
<th className="py-2 pr-4 font-medium">
{t("stats.colStudentCount")}
</th>
<th className="py-2 pr-4 font-medium">{t("stats.colAvgScore")}</th>
<th className="py-2 pr-4 font-medium">{t("stats.colMaxScore")}</th>
<th className="py-2 pr-4 font-medium">{t("stats.colMinScore")}</th>
<th className="py-2 pr-4 font-medium">{t("stats.colPassRate")}</th>
<th className="py-2 pr-4 font-medium">{t("stats.colPassCount")}</th>
<th className="py-2 pr-4 font-medium">{t("stats.colFailCount")}</th>
</tr>
</thead>
<tbody>
{summaries.map((s) => (
<tr
key={`${s.classId}-${s.subjectId}`}
className="border-b last:border-0"
>
<td className="py-2 pr-4 font-medium">{s.subjectName}</td>
<td className="py-2 pr-4 text-xs">{s.className}</td>
<td className="py-2 pr-4">{s.studentCount}</td>
<td className="py-2 pr-4 font-medium">
{formatScore(s.avgScore)}
</td>
<td className="py-2 pr-4">{formatScore(s.maxScore)}</td>
<td className="py-2 pr-4">{formatScore(s.minScore)}</td>
<td className="py-2 pr-4">
<span className={scoreRateToColorClass(s.passRate, 1)}>
{formatPassRate(s.passRate)}
</span>
</td>
<td className="py-2 pr-4">{s.passCount}</td>
<td className="py-2 pr-4 text-destructive">{s.failCount}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
/**
* 成绩管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 grades(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#grades-list
*
* URL 状态:?classId=xxx &examId=xxx &studentId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Award } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useGradesList, type GradeListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatGradeDate,
formatGradeStatus,
formatScore,
scoreRateToColorClass,
} from "@/features/teacher/grades/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function GradesListClient(): React.ReactElement {
const t = useTranslations("grades");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "cls-001";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useGradesList(classId);
// 客户端二次筛选status + q
const filteredItems = useMemo<GradeListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (statusFilter && item.status !== statusFilter) return false;
if (
q &&
!item.studentName.toLowerCase().includes(q.toLowerCase()) &&
!item.studentNo.toLowerCase().includes(q.toLowerCase())
) {
return false;
}
return true;
});
}, [data, 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/teacher/grades?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("list.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<Award className="size-6" />}
actions={
<Button asChild>
<Link href={`/shell/teacher/grades/entry?classId=${classId}`}>
{t("list.entry")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="GRADED">{t("list.statusGraded")}</option>
<option value="PENDING">{t("list.statusPending")}</option>
<option value="EXEMPT">{t("list.statusExempt")}</option>
<option value="ABSENT">{t("list.statusAbsent")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<GradesTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 成绩列表表格。
*/
function GradesTable({
items,
}: {
items: GradeListItem[];
}): React.ReactElement {
const t = useTranslations("grades");
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.colStudent")}
</th>
<th className="p-3 text-left font-medium">{t("list.colClass")}</th>
<th className="p-3 text-left font-medium">{t("list.colSource")}</th>
<th className="p-3 text-left font-medium">{t("list.colScore")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colGradedAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((grade) => (
<tr key={grade.id} className="hover:bg-muted/30">
<td className="p-3">
<p className="font-medium">{grade.studentName}</p>
<p className="mt-1 text-xs text-muted-foreground">
{grade.studentNo}
</p>
</td>
<td className="p-3 text-xs">{grade.className}</td>
<td className="p-3 text-xs">
{grade.examName ?? grade.homeworkTitle ?? "--"}
</td>
<td className="p-3">
<span
className={`font-semibold ${scoreRateToColorClass(grade.score, grade.totalScore)}`}
>
{formatScore(grade.score)}
</span>
<span className="ml-1 text-xs text-muted-foreground">
/ {formatScore(grade.totalScore)}
</span>
</td>
<td className="p-3">
<GradeStatusBadge status={grade.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatGradeDate(grade.gradedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 成绩状态徽章。
*/
function GradeStatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatGradeStatus(status);
const cls =
status === "GRADED"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: status === "PENDING"
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
: status === "EXEMPT"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: status === "ABSENT"
? "bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground";
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
/**
* 学生成绩单页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - reportCard(studentId, period):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#report-card
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useReportCard, type ReportCardEntry } from "@/lib/api";
import {
DetailField,
DetailPageShell,
DetailPageSkeleton,
DetailSection,
} from "@/shared/components/page-templates";
import {
formatGradeDate,
formatScore,
levelToColorClass,
} from "@/features/teacher/grades/transformations";
/**
* 成绩单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ReportCardClient(): React.ReactElement {
const t = useTranslations("grades");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const studentId = searchParams.get("studentId") ?? "stu-001";
const period = searchParams.get("period") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useReportCard(studentId, period);
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={
data
? t("reportCard.titleWithName", { name: data.studentName })
: t("reportCard.title")
}
description={
data
? t("reportCard.subtitle", {
no: data.studentNo,
className: data.className,
period: data.period,
})
: undefined
}
icon={<FileText className="size-6" />}
backHref="/shell/teacher/grades"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("reportCard.notFound")}
</div>
) : undefined
}
>
{data ? <ReportCardBody data={data} /> : null}
</DetailPageShell>
);
}
/**
* 成绩单内容区(学生信息 + 科目成绩 + 总评)。
*/
function ReportCardBody({
data,
}: {
data: NonNullable<ReturnType<typeof useReportCard>["data"]>;
}): React.ReactElement {
const t = useTranslations("grades");
return (
<>
<DetailSection title={t("reportCard.sectionBasic")}>
<DetailField
label={t("reportCard.fieldStudentName")}
value={data.studentName}
/>
<DetailField
label={t("reportCard.fieldStudentNo")}
value={data.studentNo}
/>
<DetailField
label={t("reportCard.fieldClass")}
value={data.className}
/>
<DetailField label={t("reportCard.fieldPeriod")} value={data.period} />
</DetailSection>
<DetailSection title={t("reportCard.sectionEntries")}>
<ReportCardTable entries={data.entries} />
</DetailSection>
<DetailSection title={t("reportCard.sectionSummary")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<SummaryStat
label={t("reportCard.totalAvgScore")}
value={formatScore(data.totalAvgScore)}
/>
<SummaryStat
label={t("reportCard.totalRank")}
value={t("reportCard.rankValue", {
rank: data.totalRank,
total: data.totalStudents,
})}
/>
<SummaryStat
label={t("reportCard.totalStudents")}
value={String(data.totalStudents)}
/>
</div>
</DetailSection>
</>
);
}
/**
* 成绩单科目表格。
*/
function ReportCardTable({
entries,
}: {
entries: ReportCardEntry[];
}): React.ReactElement {
const t = useTranslations("grades");
if (entries.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("reportCard.emptyEntries")}
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">
{t("reportCard.colSubject")}
</th>
<th className="py-2 pr-4 font-medium">
{t("reportCard.colSource")}
</th>
<th className="py-2 pr-4 font-medium">
{t("reportCard.colScore")}
</th>
<th className="py-2 pr-4 font-medium">
{t("reportCard.colLevel")}
</th>
<th className="py-2 pr-4 font-medium">
{t("reportCard.colFeedback")}
</th>
<th className="py-2 pr-4 font-medium">
{t("reportCard.colGradedAt")}
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.subjectId} className="border-b last:border-0">
<td className="py-2 pr-4 font-medium">{entry.subjectName}</td>
<td className="py-2 pr-4 text-xs">
{entry.examName ?? entry.homeworkTitle ?? "--"}
</td>
<td className="py-2 pr-4">
<span className="font-semibold">
{formatScore(entry.score)}
</span>
<span className="ml-1 text-xs text-muted-foreground">
/ {formatScore(entry.totalScore)}
</span>
</td>
<td className="py-2 pr-4">
<span className={levelToColorClass(entry.gradeLevel)}>
{entry.gradeLevel}
</span>
</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">
{entry.feedback ?? "--"}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{formatGradeDate(entry.gradedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 总评统计卡片。
*/
function SummaryStat({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,248 @@
/**
* Grades 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Grade, GradeListItem } from "@/lib/api";
/** 成绩状态中文标签映射 */
export const GRADE_STATUS_LABEL: Record<string, string> = {
GRADED: "已评分",
PENDING: "待评分",
EXEMPT: "免考",
ABSENT: "缺考",
};
/** 等级中文标签映射 */
export const GRADE_LEVEL_LABEL: Record<string, string> = {
A: "优秀",
B: "良好",
C: "合格",
D: "不合格",
};
/**
* 将成绩状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatGradeStatus(status: string): string {
return GRADE_STATUS_LABEL[status] ?? status;
}
/**
* 将等级A/B/C/D映射为中文标签。
* 未知等级回退为原始值。
*/
export function formatGradeLevel(level: string): string {
return GRADE_LEVEL_LABEL[level] ?? level;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN
* 输入无效时返回占位符。
*/
export function formatGradeDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 将 schema 的 score/totalScoreString 或 Number转为数值用于展示。
* 转换失败返回 0。
*/
export function parseScore(score: string | number): number {
const n = typeof score === "number" ? score : Number(score);
return Number.isFinite(n) ? n : 0;
}
/**
* 格式化分数(数值)为展示字符串,保留 1 位小数。
* 输入无效返回 "--"。
*/
export function formatScore(score: number): string {
if (!Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
* 输入无效返回 "--"。
*/
export function formatPassRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 将 0-1 的小数格式化为百分比字符串。
* 与 formatPassRate 同义,语义区分用于不同场景。
*/
export function formatPercent(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 根据得分比例0-1返回等级标签A/B/C/D
* - rate >= 0.85 → A
* - rate >= 0.7 → B
* - rate >= 0.6 → C
* - 其他 → D
*/
export function scoreToLevel(score: number, totalScore: number): string {
if (
!Number.isFinite(score) ||
!Number.isFinite(totalScore) ||
totalScore <= 0
) {
return "--";
}
const rate = score / totalScore;
if (rate >= 0.85) return "A";
if (rate >= 0.7) return "B";
if (rate >= 0.6) return "C";
return "D";
}
/**
* 判断成绩是否及格score >= passScore默认 passScore 为 totalScore * 0.6)。
*/
export function isPassing(
score: number,
totalScore: number,
passScore?: number,
): boolean {
if (
!Number.isFinite(score) ||
!Number.isFinite(totalScore) ||
totalScore <= 0
) {
return false;
}
const threshold = passScore ?? totalScore * 0.6;
return score >= threshold;
}
/**
* 根据得分比例返回 Tailwind 文本语义类名。
* - >= 0.8 → text-emerald-600
* - >= 0.6 → text-amber-600
* - 其他 → text-destructive
*/
export function scoreRateToColorClass(
score: number,
totalScore: number,
): string {
if (
!Number.isFinite(score) ||
!Number.isFinite(totalScore) ||
totalScore <= 0
) {
return "text-muted-foreground";
}
const rate = score / totalScore;
if (rate >= 0.8) return "text-emerald-600";
if (rate >= 0.6) return "text-amber-600";
return "text-destructive";
}
/**
* 根据等级A/B/C/D返回 Tailwind 文本语义类名。
*/
export function levelToColorClass(level: string): string {
switch (level) {
case "A":
return "text-emerald-600";
case "B":
return "text-blue-600";
case "C":
return "text-amber-600";
case "D":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
/**
* 计算分数段分布0-59, 60-69, 70-79, 80-89, 90-100
* 返回按分数段排序的数组。
*/
export function calcGradeDistribution(
scores: ReadonlyArray<{ score: number; totalScore: number }>,
): Array<{ label: string; count: number }> {
const buckets = [
{ label: "0-59", count: 0 },
{ label: "60-69", count: 0 },
{ label: "70-79", count: 0 },
{ label: "80-89", count: 0 },
{ label: "90-100", count: 0 },
];
for (const item of scores) {
if (
!Number.isFinite(item.score) ||
!Number.isFinite(item.totalScore) ||
item.totalScore <= 0
) {
continue;
}
const rate = item.score / item.totalScore;
if (rate < 0.6) {
buckets[0]!.count++;
} else if (rate < 0.7) {
buckets[1]!.count++;
} else if (rate < 0.8) {
buckets[2]!.count++;
} else if (rate < 0.9) {
buckets[3]!.count++;
} else {
buckets[4]!.count++;
}
}
return buckets;
}
/**
* 从成绩详情中提取列表项视图模型(裁剪字段)。
* 注意Grade 的 score/totalScore 是 stringGradeListItem 的是 number。
*/
export function toGradeListItem(
grade: Grade,
extra?: {
studentName?: string;
studentNo?: string;
classId?: string;
className?: string;
examName?: string | null;
homeworkTitle?: string | null;
status?: string;
},
): GradeListItem {
return {
id: grade.id,
studentId: grade.studentId,
studentName: extra?.studentName ?? "--",
studentNo: extra?.studentNo ?? "--",
classId: extra?.classId ?? "",
className: extra?.className ?? "--",
examId: grade.examId,
examName: extra?.examName ?? null,
homeworkId: grade.homeworkId,
homeworkTitle: extra?.homeworkTitle ?? null,
score: parseScore(grade.score),
totalScore: parseScore(grade.totalScore),
status: extra?.status ?? "GRADED",
gradedAt: grade.updatedAt,
gradedBy: grade.gradedBy,
};
}

View File

@@ -0,0 +1,373 @@
/**
* Homework 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Homework } from "@/lib/api";
import {
HOMEWORK_STATUS_LABEL,
SUBMISSION_STATUS_LABEL,
calcSubmissionRate,
confidenceToColorClass,
formatConfidence,
formatDueDate,
formatGradeLevel,
formatGracePeriod,
formatHomeworkStatus,
formatScore,
formatSubmissionRate,
formatSubmissionStatus,
gradeLevelToColorClass,
homeworkStatusToBadgeClass,
isGraded,
isHomeworkEditable,
isHomeworkPublished,
isOverdue,
isPendingGrading,
submissionStatusToBadgeClass,
toHomeworkListItem,
} from "../transformations";
describe("formatHomeworkStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatHomeworkStatus("DRAFT")).toBe("草稿");
expect(formatHomeworkStatus("PUBLISHED")).toBe("已发布");
expect(formatHomeworkStatus("CLOSED")).toBe("已关闭");
expect(formatHomeworkStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatHomeworkStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatHomeworkStatus("")).toBe("");
});
it("HOMEWORK_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(HOMEWORK_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatSubmissionStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatSubmissionStatus("SUBMITTED")).toBe("已提交");
expect(formatSubmissionStatus("GRADING")).toBe("批改中");
expect(formatSubmissionStatus("GRADED")).toBe("已批改");
expect(formatSubmissionStatus("RETURNED")).toBe("已退回");
expect(formatSubmissionStatus("LATE")).toBe("迟交");
});
it("returns original value for unknown status", () => {
expect(formatSubmissionStatus("UNKNOWN")).toBe("UNKNOWN");
});
it("SUBMISSION_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(SUBMISSION_STATUS_LABEL)).toHaveLength(5);
});
});
describe("formatDueDate", () => {
it("formats valid ISO date string", () => {
const result = formatDueDate("2026-07-25T23:59:59Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatDueDate(null)).toBe("--");
expect(formatDueDate(undefined)).toBe("--");
expect(formatDueDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatDueDate("not-a-date")).toBe("--");
});
});
describe("formatGracePeriod", () => {
it("formats hours under 24", () => {
expect(formatGracePeriod(12)).toBe("12 小时");
expect(formatGracePeriod(1)).toBe("1 小时");
});
it("formats exact days", () => {
expect(formatGracePeriod(24)).toBe("1 天");
expect(formatGracePeriod(48)).toBe("2 天");
});
it("formats days with remainder hours", () => {
expect(formatGracePeriod(36)).toBe("1 天 12 小时");
expect(formatGracePeriod(25)).toBe("1 天 1 小时");
});
it("returns placeholder for invalid or zero input", () => {
expect(formatGracePeriod(0)).toBe("无宽限期");
expect(formatGracePeriod(-1)).toBe("无宽限期");
expect(formatGracePeriod(Number.NaN)).toBe("无宽限期");
});
});
describe("isOverdue", () => {
it("returns true when dueDate is in the past", () => {
const past = "2020-01-01T00:00:00Z";
expect(isOverdue(past, new Date("2026-07-22T00:00:00Z"))).toBe(true);
});
it("returns false when dueDate is in the future", () => {
const future = "2030-01-01T00:00:00Z";
expect(isOverdue(future, new Date("2026-07-22T00:00:00Z"))).toBe(false);
});
it("returns false for null/undefined/invalid date", () => {
expect(isOverdue(null)).toBe(false);
expect(isOverdue(undefined)).toBe(false);
expect(isOverdue("not-a-date")).toBe(false);
});
it("returns false when dueDate equals now (not strictly past)", () => {
const now = new Date("2026-07-22T00:00:00Z");
expect(isOverdue("2026-07-22T00:00:00Z", now)).toBe(false);
});
});
describe("isHomeworkEditable / isHomeworkPublished", () => {
it("DRAFT is editable but not published", () => {
expect(isHomeworkEditable("DRAFT")).toBe(true);
expect(isHomeworkPublished("DRAFT")).toBe(false);
});
it("PUBLISHED is published but not editable", () => {
expect(isHomeworkEditable("PUBLISHED")).toBe(false);
expect(isHomeworkPublished("PUBLISHED")).toBe(true);
});
it("unknown status is neither editable nor published", () => {
expect(isHomeworkEditable("UNKNOWN")).toBe(false);
expect(isHomeworkPublished("UNKNOWN")).toBe(false);
});
});
describe("toHomeworkListItem", () => {
it("extracts list fields from full homework", () => {
const hw: Homework = {
id: "hw-001",
classId: "cls-001",
subjectId: "sub-math",
title: "集合练习",
description: "完成教材练习",
dueDate: "2026-07-25T23:59:59Z",
gracePeriod: 24,
status: "PUBLISHED",
schoolId: "sch-001",
createdBy: "usr-001",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toHomeworkListItem(hw);
expect(item.id).toBe("hw-001");
expect(item.title).toBe("集合练习");
expect(item.status).toBe("PUBLISHED");
expect(item).not.toHaveProperty("schoolId");
expect(item).not.toHaveProperty("createdBy");
expect(item).not.toHaveProperty("updatedAt");
});
it("handles null description", () => {
const hw: Homework = {
id: "hw-002",
classId: "cls-001",
subjectId: "sub-math",
title: "无描述作业",
description: null,
dueDate: "2026-07-25T23:59:59Z",
gracePeriod: 12,
status: "DRAFT",
schoolId: "sch-001",
createdBy: "usr-001",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toHomeworkListItem(hw);
expect(item.description).toBeNull();
});
});
describe("isGraded / isPendingGrading", () => {
it("GRADED and RETURNED are graded", () => {
expect(isGraded("GRADED")).toBe(true);
expect(isGraded("RETURNED")).toBe(true);
});
it("SUBMITTED and LATE are pending grading", () => {
expect(isPendingGrading("SUBMITTED")).toBe(true);
expect(isPendingGrading("LATE")).toBe(true);
});
it("GRADED is not pending", () => {
expect(isPendingGrading("GRADED")).toBe(false);
});
it("GRADING is neither graded nor pending", () => {
expect(isGraded("GRADING")).toBe(false);
expect(isPendingGrading("GRADING")).toBe(false);
});
});
describe("formatScore", () => {
it("formats finite numbers with 1 decimal place", () => {
expect(formatScore(82.5)).toBe("82.5");
expect(formatScore(98)).toBe("98.0");
expect(formatScore(0)).toBe("0.0");
});
it("returns placeholder for null/undefined", () => {
expect(formatScore(null)).toBe("--");
expect(formatScore(undefined)).toBe("--");
});
it("returns placeholder for non-finite input", () => {
expect(formatScore(Number.NaN)).toBe("--");
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
});
});
describe("formatSubmissionRate", () => {
it("formats rate in [0,1] as percentage", () => {
expect(formatSubmissionRate(0.789)).toBe("79%");
expect(formatSubmissionRate(0)).toBe("0%");
expect(formatSubmissionRate(1)).toBe("100%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatSubmissionRate(-0.1)).toBe("--");
expect(formatSubmissionRate(1.1)).toBe("--");
expect(formatSubmissionRate(Number.NaN)).toBe("--");
});
});
describe("calcSubmissionRate", () => {
it("calculates rate correctly", () => {
expect(calcSubmissionRate(30, 38)).toBeCloseTo(0.789, 2);
expect(calcSubmissionRate(0, 38)).toBe(0);
expect(calcSubmissionRate(38, 38)).toBe(1);
});
it("caps at 1 when submitted > total", () => {
expect(calcSubmissionRate(40, 38)).toBe(1);
});
it("returns 0 for zero or invalid total", () => {
expect(calcSubmissionRate(10, 0)).toBe(0);
expect(calcSubmissionRate(10, -1)).toBe(0);
});
it("returns 0 for negative submitted count", () => {
expect(calcSubmissionRate(-5, 38)).toBe(0);
});
});
describe("formatGradeLevel", () => {
it("maps score rate to A/B/C/D levels", () => {
expect(formatGradeLevel(90, 100)).toBe("A");
expect(formatGradeLevel(85, 100)).toBe("A");
expect(formatGradeLevel(75, 100)).toBe("B");
expect(formatGradeLevel(70, 100)).toBe("B");
expect(formatGradeLevel(65, 100)).toBe("C");
expect(formatGradeLevel(60, 100)).toBe("C");
expect(formatGradeLevel(50, 100)).toBe("D");
expect(formatGradeLevel(0, 100)).toBe("D");
});
it("returns placeholder for null/undefined score", () => {
expect(formatGradeLevel(null, 100)).toBe("--");
expect(formatGradeLevel(undefined, 100)).toBe("--");
});
it("returns placeholder for zero or invalid maxScore", () => {
expect(formatGradeLevel(80, 0)).toBe("--");
expect(formatGradeLevel(80, -1)).toBe("--");
});
});
describe("gradeLevelToColorClass", () => {
it("maps each level to correct color class", () => {
expect(gradeLevelToColorClass("A")).toBe("text-emerald-600");
expect(gradeLevelToColorClass("B")).toBe("text-blue-600");
expect(gradeLevelToColorClass("C")).toBe("text-amber-600");
expect(gradeLevelToColorClass("D")).toBe("text-destructive");
});
it("returns muted for unknown level", () => {
expect(gradeLevelToColorClass("X")).toBe("text-muted-foreground");
expect(gradeLevelToColorClass("")).toBe("text-muted-foreground");
});
});
describe("submissionStatusToBadgeClass", () => {
it("returns correct badge class for each status", () => {
expect(submissionStatusToBadgeClass("SUBMITTED")).toContain("amber");
expect(submissionStatusToBadgeClass("LATE")).toContain("amber");
expect(submissionStatusToBadgeClass("GRADING")).toContain("primary");
expect(submissionStatusToBadgeClass("GRADED")).toContain("emerald");
expect(submissionStatusToBadgeClass("RETURNED")).toContain("blue");
});
it("returns muted for unknown status", () => {
expect(submissionStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("homeworkStatusToBadgeClass", () => {
it("returns correct badge class for each status", () => {
expect(homeworkStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(homeworkStatusToBadgeClass("PUBLISHED")).toContain("primary");
expect(homeworkStatusToBadgeClass("CLOSED")).toContain("amber");
});
it("returns muted for unknown status", () => {
expect(homeworkStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("confidenceToColorClass", () => {
it("returns emerald for high confidence", () => {
expect(confidenceToColorClass(0.8)).toBe("text-emerald-600");
expect(confidenceToColorClass(0.95)).toBe("text-emerald-600");
});
it("returns amber for medium confidence", () => {
expect(confidenceToColorClass(0.6)).toBe("text-amber-600");
expect(confidenceToColorClass(0.79)).toBe("text-amber-600");
});
it("returns destructive for low confidence", () => {
expect(confidenceToColorClass(0.59)).toBe("text-destructive");
expect(confidenceToColorClass(0)).toBe("text-destructive");
});
it("returns muted for non-finite input", () => {
expect(confidenceToColorClass(Number.NaN)).toBe("text-muted-foreground");
});
});
describe("formatConfidence", () => {
it("formats confidence in [0,1] as percentage", () => {
expect(formatConfidence(0.92)).toBe("92%");
expect(formatConfidence(0)).toBe("0%");
expect(formatConfidence(1)).toBe("100%");
});
it("returns placeholder for out-of-range or non-finite input", () => {
expect(formatConfidence(-0.1)).toBe("--");
expect(formatConfidence(1.1)).toBe("--");
expect(formatConfidence(Number.NaN)).toBe("--");
});
});

View File

@@ -0,0 +1,437 @@
"use client";
/**
* 按作业批量批改页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 查询 assignmentSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 查询 aiBatchGrading(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Layers } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
useAssignmentSubmissions,
useAiBatchGrading,
type AiGradingSuggestion,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
calcSubmissionRate,
confidenceToColorClass,
formatConfidence,
formatDueDate,
formatScore,
formatSubmissionRate,
formatSubmissionStatus,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 批量批改客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AssignmentSubmissionsClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAssignmentSubmissions(homeworkId);
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>
<p className="mt-2 text-xs text-muted-foreground">
{t("assignment.mswNotice")}
</p>
</div>
) : undefined;
const homeworkTitle = data?.homework.title ?? "";
const stats = data?.stats;
const submissions = data?.submissions ?? [];
return (
<ListPageShell
title={t("assignment.title")}
description={
homeworkTitle
? t("assignment.description", { title: homeworkTitle })
: t("assignment.descriptionLoading")
}
icon={<Layers className="size-6" />}
actions={
<Button asChild variant="outline">
<Link href={`/shell/teacher/homework/${homeworkId}`}>
{t("assignment.backToHomework")}
</Link>
</Button>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={6} />}
empty={!loading && !error && submissions.length === 0}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("assignment.total", { count: submissions.length })}</span>
</div>
}
>
{data ? (
<AssignmentSubmissionsBody
homeworkId={homeworkId}
homeworkTitle={homeworkTitle}
dueDate={data.homework.dueDate}
className={data.homework.className}
stats={stats}
submissions={submissions}
/>
) : null}
</ListPageShell>
);
}
/**
* 主体:统计卡片 + AI 建议区 + 提交表格。
*/
function AssignmentSubmissionsBody({
homeworkId,
homeworkTitle,
dueDate,
className,
stats,
submissions,
}: {
homeworkId: string;
homeworkTitle: string;
dueDate: string;
className: string;
stats:
| {
totalStudents: number;
submittedCount: number;
gradedCount: number;
pendingCount: number;
avgScore: number;
submissionRate: number;
}
| undefined;
submissions: ReadonlyArray<{
id: string;
homeworkId: string;
homeworkTitle: string;
studentId: string;
studentName: string;
studentNo: string;
classId: string;
className: string;
status: string;
submittedAt: string | null;
gradedAt: string | null;
gradedBy: string | null;
totalScore: number | null;
maxScore: number;
}>;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="space-y-6">
{/* 统计卡片区 */}
{stats ? <StatsCard stats={stats} /> : null}
{/* 作业信息 */}
<section className="rounded-xl border bg-card p-4">
<h2 className="mb-2 text-sm font-semibold">
{t("assignment.sectionHomework")}
</h2>
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<InfoItem
label={t("assignment.homeworkTitle")}
value={homeworkTitle}
/>
<InfoItem label={t("assignment.homeworkId")} value={homeworkId} />
<InfoItem label={t("assignment.className")} value={className} />
<InfoItem
label={t("assignment.dueDate")}
value={formatDueDate(dueDate)}
/>
</div>
</section>
{/* AI 批量评分区 */}
<AiBatchGradingSection homeworkId={homeworkId} />
{/* 提交列表 */}
<section className="rounded-xl border">
<header className="border-b bg-muted/30 p-3">
<h2 className="text-sm font-semibold">
{t("assignment.sectionSubmissions")}
</h2>
</header>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("assignment.colStudent")}
</th>
<th className="p-3 text-left font-medium">
{t("assignment.colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("assignment.colSubmittedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("assignment.colScore")}
</th>
<th className="p-3 text-right font-medium">
{t("assignment.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{submissions.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="font-medium hover:underline"
>
{s.studentName}
</Link>
<p className="text-xs text-muted-foreground">
{s.studentNo}
</p>
</td>
<td className="p-3">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
>
{formatSubmissionStatus(s.status)}
</span>
</td>
<td className="p-3 font-mono text-xs">
{formatDueDate(s.submittedAt)}
</td>
<td className="p-3 text-right">
{formatScore(s.totalScore)} / {s.maxScore}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/homework/submissions/${s.id}/scan-grading`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("assignment.scanGrade")}
</Link>
<span className="mx-1 text-muted-foreground/40">·</span>
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("assignment.detailGrade")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
/**
* 统计卡片4 个指标 + 提交率)。
*/
function StatsCard({
stats,
}: {
stats: {
totalStudents: number;
submittedCount: number;
gradedCount: number;
pendingCount: number;
avgScore: number;
submissionRate: number;
};
}): React.ReactElement {
const t = useTranslations("homework");
const items = [
{
label: t("assignment.statsTotalStudents"),
value: String(stats.totalStudents),
},
{
label: t("assignment.statsSubmitted"),
value: String(stats.submittedCount),
},
{
label: t("assignment.statsGraded"),
value: String(stats.gradedCount),
},
{
label: t("assignment.statsPending"),
value: String(stats.pendingCount),
},
{
label: t("assignment.statsAvgScore"),
value: formatScore(stats.avgScore),
},
{
label: t("assignment.statsSubmissionRate"),
value: formatSubmissionRate(
calcSubmissionRate(stats.submittedCount, stats.totalStudents),
),
},
];
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{items.map((it) => (
<div key={it.label} className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">{it.label}</p>
<p className="mt-1 text-lg font-semibold">{it.value}</p>
</div>
))}
</section>
);
}
/**
* AI 批量评分区(点击按钮触发拉取建议 + 显示建议列表)。
*/
function AiBatchGradingSection({
homeworkId,
}: {
homeworkId: string;
}): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const { data, loading, error, refetch } = useAiBatchGrading(homeworkId, {
enabled: false,
});
const handleTrigger = (): void => {
void refetch();
notify.info(t("assignment.aiTriggered"));
};
if (loading) {
return (
<section className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
</section>
);
}
if (error) {
return (
<section className="rounded-xl border bg-card p-4">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</section>
);
}
const suggestions = data?.suggestions ?? [];
const summary = data?.summary;
return (
<section className="rounded-xl border bg-card p-4">
<header className="mb-3 flex items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">
{t("assignment.sectionAiGrading")}
</h2>
{summary ? (
<p className="mt-1 text-xs text-muted-foreground">
{t("assignment.aiSummary", {
processed: summary.processed,
total: summary.totalSubmissions,
avg: formatConfidence(summary.avgConfidence),
})}
</p>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTrigger}
>
{t("assignment.aiBatchGrade")}
</Button>
</header>
{suggestions.length > 0 ? (
<ul className="space-y-2">
{suggestions.map((s) => (
<AiSuggestionRow key={s.submissionId} suggestion={s} />
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
{t("assignment.aiEmpty")}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t("assignment.aiContractPending")}
</p>
</section>
);
}
function AiSuggestionRow({
suggestion,
}: {
suggestion: AiGradingSuggestion;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<li className="flex flex-col gap-1 rounded-md border p-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{suggestion.studentName}</p>
<p className="text-xs text-muted-foreground">{suggestion.reasoning}</p>
</div>
<div className="flex items-center gap-3 text-xs">
<span className="font-mono">
{t("assignment.suggestedScore")}:{" "}
<span className="font-semibold">{suggestion.suggestedScore}</span>
</span>
<span className={confidenceToColorClass(suggestion.confidence)}>
{t("assignment.confidence")}:{" "}
{formatConfidence(suggestion.confidence)}
</span>
</div>
</li>
);
}
function InfoItem({
label,
value,
}: {
label: string;
value: string;
}): React.ReactElement {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,394 @@
"use client";
/**
* 作业详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 homework(id: ID!):✅ schema 真实字段core-edu 子图)
* - 列表查询 homeworkSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation recordGrade(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
useHomework,
useHomeworkSubmissions,
useRecordGrade,
type Homework,
type HomeworkSubmissionItem,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatDueDate,
formatGracePeriod,
formatHomeworkStatus,
formatScore,
formatSubmissionStatus,
homeworkStatusToBadgeClass,
isHomeworkEditable,
isOverdue,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function HomeworkDetailClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const homeworkId = params?.id ?? "";
// ✅ 真实查询homework(id: ID!)schema 已就绪
const { data, loading, error } = useHomework(homeworkId);
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={data?.title ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatDueDate(data.createdAt),
})
: undefined
}
icon={<ClipboardList className="size-6" />}
backHref="/shell/teacher/homework"
actions={
data && isHomeworkEditable(data.status) ? (
<Button variant="outline">{t("detail.edit")}</Button>
) : null
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("detail.notFound")}
</div>
) : undefined
}
>
{data ? <HomeworkDetailBody homework={data} /> : null}
</DetailPageShell>
);
}
/**
* 详情内容区(基本信息 + 提交列表 + 内联批改表单)。
*/
function HomeworkDetailBody({
homework,
}: {
homework: Homework;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<>
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldTitle")} value={homework.title} />
<DetailField
label={t("detail.fieldDescription")}
value={homework.description ?? "-"}
/>
<DetailField
label={t("detail.fieldDueDate")}
value={
<span
className={isOverdue(homework.dueDate) ? "text-destructive" : ""}
>
{formatDueDate(homework.dueDate)}
</span>
}
/>
<DetailField
label={t("detail.fieldGracePeriod")}
value={formatGracePeriod(homework.gracePeriod)}
/>
<DetailField
label={t("detail.fieldStatus")}
value={
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${homeworkStatusToBadgeClass(homework.status)}`}
>
{formatHomeworkStatus(homework.status)}
</span>
}
/>
<DetailField
label={t("detail.fieldClassId")}
value={homework.classId}
/>
<DetailField
label={t("detail.fieldSubjectId")}
value={homework.subjectId}
/>
<DetailField
label={t("detail.fieldCreatedBy")}
value={homework.createdBy}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatDueDate(homework.updatedAt)}
/>
</DetailSection>
<DetailSection
title={t("detail.sectionSubmissions")}
actions={
<Button asChild variant="outline" size="sm">
<Link
href={`/shell/teacher/homework/assignments/${homework.id}/submissions`}
>
{t("detail.viewAllSubmissions")}
</Link>
</Button>
}
>
<HomeworkSubmissionsSection homeworkId={homework.id} />
</DetailSection>
<DetailSection title={t("detail.sectionInlineGrade")}>
<InlineGradeForm homeworkId={homework.id} />
</DetailSection>
</>
);
}
/**
* 提交列表(按 homeworkId 拉取,@contract-pending MSW 兜底)。
*/
function HomeworkSubmissionsSection({
homeworkId,
}: {
homeworkId: string;
}): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const { data, loading, error } = useHomeworkSubmissions({ homeworkId });
if (loading) {
return (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
);
}
if (error) {
return (
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
);
}
const items = data?.items ?? [];
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{t("detail.noSubmissions")}
</p>
);
}
return (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("detail.colStudent")}
</th>
<th className="p-2 text-left font-medium">
{t("detail.colStatus")}
</th>
<th className="p-2 text-left font-medium">
{t("detail.colSubmittedAt")}
</th>
<th className="p-2 text-right font-medium">
{t("detail.colScore")}
</th>
<th className="p-2 text-right font-medium">
{t("detail.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((s) => (
<SubmissionRow key={s.id} submission={s} />
))}
</tbody>
</table>
</div>
);
}
function SubmissionRow({
submission,
}: {
submission: HomeworkSubmissionItem;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<tr className="hover:bg-muted/30">
<td className="p-2">
<Link
href={`/shell/teacher/homework/submissions/${submission.id}`}
className="font-medium hover:underline"
>
{submission.studentName}
</Link>
<p className="text-xs text-muted-foreground">{submission.studentNo}</p>
</td>
<td className="p-2">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
>
{formatSubmissionStatus(submission.status)}
</span>
</td>
<td className="p-2 font-mono text-xs">
{formatDueDate(submission.submittedAt)}
</td>
<td className="p-2 text-right">
{formatScore(submission.totalScore)} / {submission.maxScore}
</td>
<td className="p-2 text-right">
<Link
href={`/shell/teacher/homework/submissions/${submission.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("detail.grade")}
</Link>
</td>
</tr>
);
}
/**
* 内联批改表单(录入单个学生单次成绩,@contract-pending MSW 兜底)。
*/
function InlineGradeForm({
homeworkId,
}: {
homeworkId: string;
}): React.ReactElement {
const t = useTranslations("homework");
const { run: recordGrade, loading: submitting } = useRecordGrade();
const [studentId, setStudentId] = useState("");
const [score, setScore] = useState("");
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (): Promise<void> => {
setError(null);
if (!studentId.trim()) {
setError(t("detail.gradeErrorStudentRequired"));
return;
}
const scoreNum = Number(score);
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
setError(t("detail.gradeErrorScoreInvalid"));
return;
}
try {
await recordGrade({
homeworkId,
studentId: studentId.trim(),
score: scoreNum,
feedback: feedback.trim() || undefined,
});
notify.success(t("detail.gradeSuccess"));
setStudentId("");
setScore("");
setFeedback("");
} catch (err) {
notify.error(`${t("detail.gradeError")}: ${String(err)}`);
}
};
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">
{t("detail.gradeStudentId")}
<span className="ml-1 text-destructive">*</span>
</label>
<input
type="text"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
placeholder="stu-001"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
{t("detail.gradeScore")}
<span className="ml-1 text-destructive">*</span>
</label>
<input
type="number"
min={0}
value={score}
onChange={(e) => setScore(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
{t("detail.gradeFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("detail.gradeFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex justify-end">
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("detail.gradeSubmitting") : t("detail.gradeSubmit")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("detail.gradeContractPending")}
</p>
</div>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
/**
* 作业管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 homeworks(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-list
*
* URL 状态:?classId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useHomeworkList, type HomeworkListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatDueDate,
formatGracePeriod,
formatHomeworkStatus,
homeworkStatusToBadgeClass,
isOverdue,
} from "@/features/teacher/homework/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function HomeworkListClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "cls-001";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useHomeworkList(classId, {
status: statusFilter || undefined,
});
// 客户端二次筛选q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<HomeworkListItem[]>(() => {
const items = data?.items ?? [];
return items.filter((item) => {
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
return false;
}
return true;
});
}, [data, 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/teacher/homework?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("list.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ClipboardList className="size-6" />}
actions={
<Button asChild>
<Link href={`/shell/teacher/homework/new?classId=${classId}`}>
{t("list.new")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="CLOSED">{t("list.statusClosed")}</option>
<option value="ARCHIVED">{t("list.statusArchived")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<HomeworkTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 作业列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function HomeworkTable({
items,
}: {
items: HomeworkListItem[];
}): React.ReactElement {
const t = useTranslations("homework");
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.colDueDate")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colGracePeriod")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((hw) => (
<tr key={hw.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/homework/${hw.id}`}
className="font-medium hover:underline"
>
{hw.title}
</Link>
{hw.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{hw.description}
</p>
) : null}
</td>
<td className="p-3">
<HomeworkStatusBadge status={hw.status} />
</td>
<td className="p-3 font-mono text-xs">
<span
className={isOverdue(hw.dueDate) ? "text-destructive" : ""}
>
{formatDueDate(hw.dueDate)}
</span>
</td>
<td className="p-3 text-xs">
{formatGracePeriod(hw.gracePeriod)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/homework/${hw.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 作业状态徽章(按状态色阶展示)。
*/
function HomeworkStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatHomeworkStatus(status);
const cls = homeworkStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
/**
* 布置作业表单页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - mutation assignHomework(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + router.push 回列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import { useAssignHomework, type AssignHomeworkInput } from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function NewHomeworkClient(): React.ReactElement {
const t = useTranslations("homework");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const presetClassId = searchParams.get("classId") ?? "";
// @contract-pendingMSW 兜底
const { run: assignHomework, loading: submitting } = useAssignHomework();
const handleSubmit = async (input: AssignHomeworkInput): Promise<void> => {
try {
const result = await assignHomework(input);
notify.success(t("new.success"));
startTransition(() => {
router.push(`/shell/teacher/homework?classId=${input.classId}`);
});
void result;
} catch (err) {
notify.error(`${t("new.error")}: ${String(err)}`);
}
};
return (
<NewHomeworkFormInner
presetClassId={presetClassId}
submitting={submitting}
onSubmit={handleSubmit}
/>
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*
* 注:未引入 react-hook-form + zod因当前仅一个表单引入会增加依赖。
* 后续表单数量增多后统一迁移到 react-hook-form§7.3 表单页模板建议)。
*/
function NewHomeworkFormInner({
presetClassId,
submitting,
onSubmit,
}: {
presetClassId: string;
submitting: boolean;
onSubmit: (input: AssignHomeworkInput) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const [classId, setClassId] = useState(presetClassId);
const [subjectId, setSubjectId] = useState("sub-math");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [dueDate, setDueDate] = useState("");
const [gracePeriod, setGracePeriod] = useState("24");
const [error, setError] = useState<string | null>(null);
const handleFormSubmit = (): void => {
setError(null);
if (!classId.trim()) {
setError(t("new.errorClassRequired"));
return;
}
if (!title.trim()) {
setError(t("new.errorTitleRequired"));
return;
}
if (!dueDate) {
setError(t("new.errorDateRequired"));
return;
}
const input: AssignHomeworkInput = {
classId: classId.trim(),
subjectId: subjectId.trim(),
title: title.trim(),
description: description.trim() || undefined,
dueDate: new Date(dueDate).toISOString(),
gracePeriod: Number(gracePeriod) || 0,
};
void onSubmit(input);
};
return (
<FormPageShell
title={t("new.title")}
description={t("new.description")}
icon={<ClipboardList className="size-6" />}
backHref={`/shell/teacher/homework?classId=${classId}`}
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("new.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
{/* 班级 ID */}
<FormField label={t("new.classId")} required>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="cls-001"
required
/>
</FormField>
{/* 科目 ID */}
<FormField label={t("new.subjectId")} required>
<input
type="text"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="sub-math"
required
/>
</FormField>
{/* 标题 */}
<FormField label={t("new.titleLabel")} required>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("new.titlePlaceholder")}
required
/>
</FormField>
{/* 描述 */}
<FormField label={t("new.descriptionLabel")}>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.descriptionPlaceholder")}
/>
</FormField>
{/* 截止日期 */}
<FormField label={t("new.dueDate")} required>
<input
type="datetime-local"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
{/* 宽限期(小时) */}
<FormField label={t("new.gracePeriod")}>
<input
type="number"
min={0}
value={gracePeriod}
onChange={(e) => setGracePeriod(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
<p className="text-xs text-muted-foreground">
{t("new.gracePeriodHint")}
</p>
</FormField>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">
{t("new.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
*/
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>
);
}

View File

@@ -0,0 +1,356 @@
"use client";
/**
* 扫描批改页 - 客户端组件ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation saveScanGrading(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingWorkbenchPageSkeletonloading=true
* - errorerrorNode 局部降级
* - 局部加载:左/中/右各自支持独立 loading本页用整体 loading
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*
* 注WorkbenchPageShell 没有 emptyNode 属性notFound 由 errorNode 节点呈现。
*/
import { ScanLine } from "lucide-react";
import { useParams } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
useSubmissionDetail,
useSaveScanGrading,
type SubmissionAnswer,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
WorkbenchPageShell,
WorkbenchPanel,
WorkbenchPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
confidenceToColorClass,
formatConfidence,
} from "@/features/teacher/homework/transformations";
/**
* 扫描批改客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ScanGradingClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ submissionId: string }>();
const submissionId = params?.submissionId ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useSubmissionDetail(submissionId);
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;
const notFoundNode =
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("scan.notFound")}
</div>
) : undefined;
return (
<WorkbenchPageShell
title={
data
? t("scan.title", { name: data.submission.studentName })
: t("scan.titleLoading")
}
description={
data
? t("scan.subtitle", {
homework: data.submission.homeworkTitle,
})
: undefined
}
icon={<ScanLine className="size-6" />}
loading={loading}
loadingNode={<WorkbenchPageSkeleton />}
errorNode={errorNode ?? notFoundNode}
left={
data ? (
<WorkbenchPanel title={t("scan.scanPreview")}>
<ScanPreviewPanel submissionId={data.submission.id} />
</WorkbenchPanel>
) : null
}
center={
data ? (
<WorkbenchPanel title={t("scan.recognizedAnswers")}>
<RecognizedAnswersPanel answers={data.answers} />
</WorkbenchPanel>
) : null
}
right={
data ? (
<WorkbenchPanel title={t("scan.gradingForm")}>
<ScanGradingForm
submissionId={data.submission.id}
answers={data.answers}
maxScore={data.submission.maxScore}
/>
</WorkbenchPanel>
) : null
}
/>
);
}
/**
* 扫描图片预览面板(左栏)。
* 注:当前无真实扫描图片,使用占位符 + 提示。
*/
function ScanPreviewPanel({
submissionId,
}: {
submissionId: string;
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="flex h-full flex-col gap-3">
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<div className="text-center">
<ScanLine className="mx-auto size-12 text-muted-foreground" />
<p className="mt-2 text-xs text-muted-foreground">
{t("scan.imagePlaceholder", { id: submissionId })}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("scan.imageContractPending")}
</p>
</div>
);
}
/**
* 已识别答案面板(中栏)。
*/
function RecognizedAnswersPanel({
answers,
}: {
answers: SubmissionAnswer[];
}): React.ReactElement {
const t = useTranslations("homework");
return (
<div className="space-y-3">
{answers.map((a, idx) => (
<div key={a.id} className="rounded-md border p-3">
<div className="mb-1 flex items-start justify-between gap-2">
<p className="text-sm font-medium">
{idx + 1}. {a.questionTitle}
</p>
<span className="text-xs text-muted-foreground">
{a.maxScore} {t("scan.unitScore")}
</span>
</div>
<p className="text-xs text-muted-foreground">
{t("scan.questionType")}: {a.questionType}
</p>
<div className="mt-2 rounded-md bg-muted/30 p-2">
<p className="text-xs text-muted-foreground">
{t("scan.recognizedAnswer")}
</p>
<p className="mt-1 text-sm">{a.answer}</p>
</div>
{a.aiSuggestion ? (
<p className="mt-2 text-xs">
<span className="font-medium text-muted-foreground">
{t("scan.aiSuggestionLabel")}
</span>
<span className="ml-1">{a.aiSuggestion}</span>
</p>
) : null}
{a.isCorrect !== null ? (
<p
className={`mt-1 text-xs ${a.isCorrect ? "text-emerald-600" : "text-destructive"}`}
>
{a.isCorrect
? t("scan.recognizedCorrect")
: t("scan.recognizedIncorrect")}
</p>
) : null}
</div>
))}
{answers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("scan.noAnswers")}</p>
) : null}
</div>
);
}
/**
* 扫描批改表单(右栏):每题分数 + 评语 + 总反馈 + 保存。
*/
function ScanGradingForm({
submissionId,
answers,
maxScore,
}: {
submissionId: string;
answers: SubmissionAnswer[];
maxScore: number;
}): React.ReactElement {
const t = useTranslations("homework");
const { run: saveScanGrading, loading: submitting } = useSaveScanGrading();
const initialScores = useMemo<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const a of answers) {
m[a.questionId] = a.score === null ? "" : String(a.score);
}
return m;
}, [answers]);
const [scores, setScores] = useState<Record<string, string>>(initialScores);
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const applyAllAi = (): void => {
const next: Record<string, string> = { ...scores };
let applied = 0;
for (const a of answers) {
if (!a.aiSuggestion) continue;
if (a.aiSuggestion.includes("满分")) {
next[a.questionId] = String(a.maxScore);
applied += 1;
}
}
setScores(next);
notify.info(t("scan.aiAppliedCount", { count: applied }));
};
const totalScore = answers.reduce((sum, a) => {
const raw = scores[a.questionId] ?? "";
const num = Number(raw);
return Number.isFinite(num) ? sum + num : sum;
}, 0);
const handleSubmit = async (): Promise<void> => {
setError(null);
const payload = answers.map((a) => {
const raw = scores[a.questionId] ?? "";
const num = Number(raw);
if (raw === "" || !Number.isFinite(num)) {
throw new Error(t("scan.errorScoreInvalid", { qid: a.questionId }));
}
return {
questionId: a.questionId,
score: num,
};
});
try {
await saveScanGrading({
submissionId,
answers: payload,
feedback: feedback.trim() || undefined,
});
notify.success(t("scan.saveSuccess"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
notify.error(`${t("scan.saveError")}: ${msg}`);
}
};
return (
<div className="flex h-full flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("scan.totalScore")}: {totalScore} / {maxScore}
</span>
<Button type="button" variant="ghost" size="sm" onClick={applyAllAi}>
{t("scan.applyAllAi")}
</Button>
</div>
<div className="space-y-3 overflow-y-auto">
{answers.map((a, idx) => {
const raw = scores[a.questionId] ?? "";
const aiConf = a.aiSuggestion
? a.aiSuggestion.includes("满分")
? 0.95
: 0.7
: null;
return (
<div key={a.id} className="space-y-1 rounded-md border p-2">
<p className="text-xs font-medium">
{idx + 1}. {a.questionTitle}
</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={a.maxScore}
value={raw}
onChange={(e) =>
setScores((prev) => ({
...prev,
[a.questionId]: e.target.value,
}))
}
className="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm"
aria-label={t("scan.scoreLabel", { qid: a.questionId })}
/>
<span className="text-xs text-muted-foreground">
/ {a.maxScore}
</span>
{aiConf !== null ? (
<span
className={`ml-auto text-xs ${confidenceToColorClass(aiConf)}`}
>
{t("scan.confidence")}: {formatConfidence(aiConf)}
</span>
) : null}
</div>
</div>
);
})}
</div>
<div className="space-y-1">
<label className="text-xs font-medium">
{t("scan.overallFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
placeholder={t("scan.overallFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("scan.saving") : t("scan.save")}
</Button>
<p className="text-xs text-muted-foreground">
{t("scan.contractPending")}
</p>
</div>
);
}

View File

@@ -0,0 +1,391 @@
"use client";
/**
* 单份提交批改页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation gradeSubmission(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardCheck } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
useSubmissionDetail,
useGradeSubmission,
type SubmissionAnswer,
type SubmissionDetail,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatDueDate,
formatSubmissionStatus,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 批改客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function SubmissionGradingClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const params = useParams<{ submissionId: string }>();
const submissionId = params?.submissionId ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useSubmissionDetail(submissionId);
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={
data
? t("grading.title", { name: data.submission.studentName })
: t("grading.titleLoading")
}
description={
data
? t("grading.subtitle", {
homework: data.submission.homeworkTitle,
no: data.submission.studentNo,
})
: undefined
}
icon={<ClipboardCheck className="size-6" />}
backHref="/shell/teacher/homework/submissions"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("grading.notFound")}
</div>
) : undefined
}
>
{data ? <SubmissionGradingBody detail={data} /> : null}
</DetailPageShell>
);
}
/**
* 批改主体:学生信息 + 题目作答 + 批改表单 + 上下导航。
*/
function SubmissionGradingBody({
detail,
}: {
detail: SubmissionDetail;
}): React.ReactElement {
const t = useTranslations("homework");
const { submission, navigation } = detail;
const router = useRouter();
const handleNav = (target: string | null): void => {
if (!target) return;
router.push(`/shell/teacher/homework/submissions/${target}`);
};
return (
<>
<DetailSection title={t("grading.sectionStudent")}>
<DetailField
label={t("grading.fieldStudentName")}
value={submission.studentName}
/>
<DetailField
label={t("grading.fieldStudentNo")}
value={submission.studentNo}
/>
<DetailField
label={t("grading.fieldClass")}
value={submission.className}
/>
<DetailField
label={t("grading.fieldHomework")}
value={
<Link
href={`/shell/teacher/homework/${submission.homeworkId}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{submission.homeworkTitle}
</Link>
}
/>
<DetailField
label={t("grading.fieldStatus")}
value={
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
>
{formatSubmissionStatus(submission.status)}
</span>
}
/>
<DetailField
label={t("grading.fieldSubmittedAt")}
value={formatDueDate(submission.submittedAt)}
/>
<DetailField
label={t("grading.fieldFeedback")}
value={submission.feedback ?? "-"}
/>
</DetailSection>
<DetailSection title={t("grading.sectionAnswers")}>
<AnswersGradingForm
submissionId={submission.id}
answers={detail.answers}
maxScore={submission.maxScore}
/>
</DetailSection>
<DetailSection title={t("grading.sectionNavigation")}>
<div className="flex items-center justify-between gap-2 text-sm">
<Button
variant="outline"
size="sm"
disabled={!navigation.prevId}
onClick={() => handleNav(navigation.prevId)}
>
{t("grading.prev")}
</Button>
<span className="text-muted-foreground">
{t("grading.position", {
current: navigation.currentIndex + 1,
total: navigation.totalCount,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={!navigation.nextId}
onClick={() => handleNav(navigation.nextId)}
>
{t("grading.next")}
</Button>
</div>
</DetailSection>
</>
);
}
/**
* 题目作答 + 批改表单。
*/
function AnswersGradingForm({
submissionId,
answers,
maxScore,
}: {
submissionId: string;
answers: SubmissionAnswer[];
maxScore: number;
}): React.ReactElement {
const t = useTranslations("homework");
const { run: gradeSubmission, loading: submitting } = useGradeSubmission();
// 每题分数与教师评语,初始化为已有值
const initialScores = useMemo<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const a of answers) {
m[a.questionId] = a.score === null ? "" : String(a.score);
}
return m;
}, [answers]);
const initialComments = useMemo<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const a of answers) {
m[a.questionId] = a.teacherComment ?? "";
}
return m;
}, [answers]);
const [scores, setScores] = useState<Record<string, string>>(initialScores);
const [comments, setComments] =
useState<Record<string, string>>(initialComments);
const [feedback, setFeedback] = useState("");
const [error, setError] = useState<string | null>(null);
const applyAiSuggestion = (answer: SubmissionAnswer): void => {
if (!answer.aiSuggestion) return;
// 解析 AI 建议(如 "答案正确,可直接给满分。" → 直接给满分)
if (answer.aiSuggestion.includes("满分")) {
setScores((prev) => ({
...prev,
[answer.questionId]: String(answer.maxScore),
}));
}
setComments((prev) => ({
...prev,
[answer.questionId]: answer.aiSuggestion ?? "",
}));
notify.info(t("grading.aiSuggestionApplied"));
};
const handleSubmit = async (): Promise<void> => {
setError(null);
const payload = answers.map((a) => {
const raw = scores[a.questionId] ?? "";
const num = Number(raw);
if (raw === "" || !Number.isFinite(num)) {
throw new Error(t("grading.errorScoreInvalid", { qid: a.questionId }));
}
return {
questionId: a.questionId,
score: num,
teacherComment: (comments[a.questionId] ?? "").trim() || undefined,
};
});
try {
await gradeSubmission({
submissionId,
answers: payload,
feedback: feedback.trim() || undefined,
});
notify.success(t("grading.success"));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
notify.error(`${t("grading.error")}: ${msg}`);
}
};
return (
<div className="space-y-4">
{answers.map((a, idx) => (
<div key={a.id} className="rounded-md border p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium">
{idx + 1}. {a.questionTitle}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("grading.questionType")}: {a.questionType} ·{" "}
{t("grading.maxScore")}: {a.maxScore}
</p>
</div>
{a.aiSuggestion ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => applyAiSuggestion(a)}
>
{t("grading.applyAi")}
</Button>
) : null}
</div>
<div className="mb-2 rounded-md bg-muted/30 p-2">
<p className="text-xs text-muted-foreground">
{t("grading.studentAnswer")}
</p>
<p className="mt-1 text-sm">{a.answer}</p>
</div>
{a.aiSuggestion ? (
<p className="mb-2 text-xs text-muted-foreground">
<span className="font-medium">
{t("grading.aiSuggestionLabel")}:
</span>{" "}
{a.aiSuggestion}
</p>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className="space-y-1">
<label className="text-xs font-medium">
{t("grading.score")}
</label>
<input
type="number"
min={0}
max={a.maxScore}
value={scores[a.questionId] ?? ""}
onChange={(e) =>
setScores((prev) => ({
...prev,
[a.questionId]: e.target.value,
}))
}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium">
{t("grading.teacherComment")}
</label>
<input
type="text"
value={comments[a.questionId] ?? ""}
onChange={(e) =>
setComments((prev) => ({
...prev,
[a.questionId]: e.target.value,
}))
}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
/>
</div>
</div>
</div>
))}
<div className="space-y-1">
<label className="text-sm font-medium">
{t("grading.overallFeedback")}
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("grading.overallFeedbackPlaceholder")}
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{t("grading.maxScoreTotal")}: {maxScore}
</p>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? t("grading.submitting") : t("grading.submit")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("grading.contractPending")}
</p>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
/**
* 提交批改列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 homeworkSubmissions(filter):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
*
* URL 状态:?classId=xxx &status=xxx &q=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardCheck } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useHomeworkSubmissions, type HomeworkSubmissionItem } from "@/lib/api";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatDueDate,
formatScore,
formatSubmissionStatus,
submissionStatusToBadgeClass,
} from "@/features/teacher/homework/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function SubmissionsListClient(): React.ReactElement {
const t = useTranslations("homework");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useHomeworkSubmissions({
classId: classId || undefined,
status: statusFilter || undefined,
});
const filteredItems = useMemo<HomeworkSubmissionItem[]>(() => {
const items = data?.items ?? [];
if (!q) return items;
return items.filter(
(item) =>
item.studentName.toLowerCase().includes(q.toLowerCase()) ||
item.studentNo.toLowerCase().includes(q.toLowerCase()) ||
item.homeworkTitle.toLowerCase().includes(q.toLowerCase()),
);
}, [data, 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/teacher/homework/submissions?${params.toString()}`);
});
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("submissions.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("submissions.title")}
description={t("submissions.description")}
icon={<ClipboardCheck className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("submissions.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={classId}
onChange={(e) => updateQuery("classId", e.target.value)}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
placeholder={t("submissions.classIdPlaceholder")}
aria-label={t("submissions.classIdFilter")}
/>
<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("submissions.statusFilter")}
>
<option value="">{t("submissions.statusAll")}</option>
<option value="SUBMITTED">
{t("submissions.statusSubmitted")}
</option>
<option value="GRADING">{t("submissions.statusGrading")}</option>
<option value="GRADED">{t("submissions.statusGraded")}</option>
<option value="RETURNED">{t("submissions.statusReturned")}</option>
<option value="LATE">{t("submissions.statusLate")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={8} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("submissions.total", { count: filteredItems.length })}</span>
</div>
}
>
<SubmissionsTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 提交列表表格(纯展示组件)。
*/
function SubmissionsTable({
items,
}: {
items: HomeworkSubmissionItem[];
}): React.ReactElement {
const t = useTranslations("homework");
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("submissions.colStudent")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colHomework")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colClass")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("submissions.colSubmittedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("submissions.colScore")}
</th>
<th className="p-3 text-right font-medium">
{t("submissions.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="font-medium hover:underline"
>
{s.studentName}
</Link>
<p className="text-xs text-muted-foreground">{s.studentNo}</p>
</td>
<td className="p-3">
<Link
href={`/shell/teacher/homework/${s.homeworkId}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{s.homeworkTitle}
</Link>
</td>
<td className="p-3 text-xs">{s.className}</td>
<td className="p-3">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
>
{formatSubmissionStatus(s.status)}
</span>
</td>
<td className="p-3 font-mono text-xs">
{formatDueDate(s.submittedAt)}
</td>
<td className="p-3 text-right">
{formatScore(s.totalScore)} / {s.maxScore}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/homework/submissions/${s.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("submissions.toGrade")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,266 @@
/**
* Homework 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Homework, HomeworkListItem } from "@/lib/api";
/** 作业状态中文标签映射(对齐旧 teacher-portal HOMEWORK_STATUS_LABEL */
export const HOMEWORK_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
CLOSED: "已关闭",
ARCHIVED: "已归档",
};
/** 提交状态中文标签映射 */
export const SUBMISSION_STATUS_LABEL: Record<string, string> = {
SUBMITTED: "已提交",
GRADING: "批改中",
GRADED: "已批改",
RETURNED: "已退回",
LATE: "迟交",
};
/**
* 将作业状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatHomeworkStatus(status: string): string {
return HOMEWORK_STATUS_LABEL[status] ?? status;
}
/**
* 将提交状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatSubmissionStatus(status: string): string {
return SUBMISSION_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatDueDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化宽限期(小时)为友好展示。
* - 0 小时:返回 "无宽限期"
* - < 24 小时:返回 "N 小时"
* - >= 24 小时:返回 "X 天 Y 小时"
*/
export function formatGracePeriod(hours: number): string {
if (!Number.isFinite(hours) || hours <= 0) return "无宽限期";
if (hours < 24) return `${hours} 小时`;
const days = Math.floor(hours / 24);
const rest = hours % 24;
return rest === 0 ? `${days}` : `${days}${rest} 小时`;
}
/**
* 判断作业是否已过截止时间(基于 dueDate
* 输入无效返回 false。
*/
export function isOverdue(
dueDate: string | null | undefined,
now: Date = new Date(),
): boolean {
if (!dueDate) return false;
const d = new Date(dueDate);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() < now.getTime();
}
/**
* 判断作业是否处于可编辑状态DRAFT
*/
export function isHomeworkEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断作业是否已发布PUBLISHED
*/
export function isHomeworkPublished(status: string): boolean {
return status === "PUBLISHED";
}
/**
* 从作业详情中提取列表项视图模型(裁剪字段)。
*/
export function toHomeworkListItem(homework: Homework): HomeworkListItem {
return {
id: homework.id,
classId: homework.classId,
subjectId: homework.subjectId,
title: homework.title,
description: homework.description,
dueDate: homework.dueDate,
gracePeriod: homework.gracePeriod,
status: homework.status,
createdAt: homework.createdAt,
};
}
/**
* 判断提交是否已完成批改GRADED / RETURNED
*/
export function isGraded(status: string): boolean {
return status === "GRADED" || status === "RETURNED";
}
/**
* 判断提交是否处于待批改状态SUBMITTED / LATE
*/
export function isPendingGrading(status: string): boolean {
return status === "SUBMITTED" || status === "LATE";
}
/**
* 将分数(数值)格式化为展示字符串,保留 1 位小数。
* 输入无效null/NaN/Infinity返回 "--"。
*/
export function formatScore(score: number | null | undefined): string {
if (score === null || score === undefined) return "--";
if (!Number.isFinite(score)) return "--";
return score.toFixed(1);
}
/**
* 将 0-1 的小数格式化为百分比字符串(如 0.789 → "79%")。
* 输入无效返回 "--"。
*/
export function formatSubmissionRate(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
return `${(rate * 100).toFixed(0)}%`;
}
/**
* 计算提交率(已提交数 / 总人数)。
* 总人数为 0 时返回 0。
*/
export function calcSubmissionRate(
submittedCount: number,
totalStudents: number,
): number {
if (!Number.isFinite(totalStudents) || totalStudents <= 0) return 0;
if (!Number.isFinite(submittedCount) || submittedCount < 0) return 0;
return Math.min(submittedCount / totalStudents, 1);
}
/**
* 根据得分率score/maxScore返回等级标签A/B/C/D
* - rate >= 0.85 → A
* - rate >= 0.7 → B
* - rate >= 0.6 → C
* - 其他 → D
*
* maxScore 为 0 或输入无效时返回 "--"。
*/
export function formatGradeLevel(
score: number | null | undefined,
maxScore: number,
): string {
if (score === null || score === undefined) return "--";
if (!Number.isFinite(score) || !Number.isFinite(maxScore) || maxScore <= 0) {
return "--";
}
const rate = score / maxScore;
if (rate >= 0.85) return "A";
if (rate >= 0.7) return "B";
if (rate >= 0.6) return "C";
return "D";
}
/**
* 根据等级A/B/C/D返回 Tailwind 文本语义类名。
*/
export function gradeLevelToColorClass(level: string): string {
switch (level) {
case "A":
return "text-emerald-600";
case "B":
return "text-blue-600";
case "C":
return "text-amber-600";
case "D":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
/**
* 根据提交状态返回 Tailwind 徽章语义类名。
*/
export function submissionStatusToBadgeClass(status: string): string {
switch (status) {
case "SUBMITTED":
case "LATE":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "GRADING":
return "bg-primary/10 text-primary";
case "GRADED":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "RETURNED":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据作业状态返回 Tailwind 徽章语义类名。
*/
export function homeworkStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "PUBLISHED":
return "bg-primary/10 text-primary";
case "CLOSED":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据 AI 评分置信度返回 Tailwind 文本语义类名。
* - >= 0.8 → text-emerald-600高置信
* - >= 0.6 → text-amber-600中置信
* - 其他 → text-destructive低置信
*/
export function confidenceToColorClass(confidence: number): string {
if (!Number.isFinite(confidence)) return "text-muted-foreground";
if (confidence >= 0.8) return "text-emerald-600";
if (confidence >= 0.6) return "text-amber-600";
return "text-destructive";
}
/**
* 格式化置信度0-1为百分比字符串如 0.92 → "92%")。
* 输入无效返回 "--"。
*/
export function formatConfidence(confidence: number): string {
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
return "--";
}
return `${(confidence * 100).toFixed(0)}%`;
}

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