feat(portal-shell): 学生域全页面迁移与规范合规修复

- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断)

- 补齐 4 个 MSW mock 缺口,修 diagnostic case 名

- 修 4 处 Tailwind 任意值;新增共享组件与路由
This commit is contained in:
SpecialX
2026-08-31 11:25:21 +08:00
parent 039db5efdd
commit 04b7a40bdc
493 changed files with 70985 additions and 2112 deletions

View File

@@ -0,0 +1,229 @@
# portal-shell 全角色模块页面核查汇总2026-08-04
> 核查日期2026-08-04
> 核查范围:教师、学生、家长、管理员四个角色域全部页面
> 核查方法:逐页面读取 page.tsx 源码,判断实现状态(完整/占位/缺失)
> 详细报告:见各角色域 NeedTodo.md 文档
---
## 一、总体统计
| 角色域 | 页面总数 | ✅ 完整实现 | 🟡 占位空态 | ❌ 缺失 | 详细报告 |
| -------- | -------- | ----------- | ----------- | ------- | -------------------------------------------- |
| 教师域 | 63 | 60 | 3 | 0 | [teacher-NeedTodo.md](./teacher-NeedTodo.md) |
| 学生域 | 32 | 32 | 0 | 0 | [student-NeedTodo.md](./student-NeedTodo.md) |
| 家长域 | 1 | 1 | 0 | 22 | [parent-NeedTodo.md](./parent-NeedTodo.md) |
| 管理员域 | 49 | 49 | 0 | 0 | [admin-NeedTodo.md](./admin-NeedTodo.md) |
| **合计** | **145** | **142** | **3** | **22** | — |
---
## 二、各角色域核查结论
### 2.1 教师域63 页)
**状态**:基本完成,仅 3 个占位页需处理
**3 个占位页(均为 CICD 重定向路径,有等价实现)**
1. `/shell/teacher/classes/students` → 重定向到 `/shell/teacher/students`
2. `/shell/teacher/exams/grading` → 重定向到 `/shell/teacher/homework/submissions`
3. `/shell/teacher/exams/grading/[submissionId]` → 重定向到 `/shell/teacher/homework/submissions/[submissionId]`
**已有资产**
- 55 个 client 组件features/teacher/
- 20 个 lib/api 模块
- 116 个 mock 数据集
**P1-P2 功能增强 backlog**15 页已集成但与 CICD 有功能差距(详见 teacher-NeedTodo.md §三.B
### 2.2 学生域32 页)
**状态**全部真实业务实现4 页 mock 数据缺口
**4 页 mock 数据缺口**
1. `learning-path` - mockMyLearningPath 缺失
2. `elective/[id]` - mockElectiveCourseDetail 缺失
3. `diagnostic` - mockDiagnosticReports 缺失
4. `elective` - 部分字段缺失
**已有资产**
- 28 页 mock 数据齐全,可完整运行
- 全部页面集成 client 组件 + API hook + 三态处理
### 2.3 家长域1 页 + 22 缺失)
**状态**:严重缺失,仅 dashboard 完成
**当前实现**
- `/shell/parent` - 完整实现useParentDashboard hook + 三态处理 + StatCard×2 + DashboardSection×2
**22 个缺失页面**(按 ARCH §9.3 规划 24 页):
1. dashboard 子页trend趋势详情、weakness薄弱点详情
2. children子女管理列表 + [studentId] 详情
3. grades成绩列表 + [id] 详情
4. exams考试列表 + [id] 详情
5. homework作业列表
6. attendance考勤列表
7. classes班级列表
8. course-plans课程计划列表 + [id] 详情
9. lesson-plans教案列表 + [planId]/view
10. error-book错题本列表
11. diagnostic诊断列表
12. learning-path学习路径列表
13. practice练习列表
14. elective选修列表
15. leave请假列表
16. preferences偏好设置列表
17. notifications通知- 共享路由
18. settings设置- 共享路由
**关键阻塞项**
- `features/parent/` 目录完全不存在,所有 client 组件待建
- `myChildren` 契约缺失(子女详情页前置阻塞)
- 5 个 API hooks 已就绪,其余 18+ 页面 hooks 未建立
- 仅 1 项 mock 数据mockParentDashboard
### 2.4 管理员域49 页)
**状态**全部落地0 占位 0 缺失
**已有资产**
- 49 个 page.tsx 入口
- 44 个 features/admin/*-client.tsx 业务组件
- lib/api hooksadmin.ts 973 行 + admin-p5.ts 3371 行)
- mocks/graphql-data.ts9867 行)兜底数据全覆盖
**契约状态**
- 仅 2 页契约就绪dashboard adminDashboard ✅ + plugins config-service ✅)
- 47 页 schema 未就绪,前端用 MSW 兜底先行(@contract-pending 标注)
---
## 三、按优先级排序的待办事项
### P0 - 必须立即处理
#### P0-1: 教师域 3 个占位页改为重定向
- `/shell/teacher/classes/students/page.tsx` → 改为 redirect 到 `/shell/teacher/students`
- `/shell/teacher/exams/grading/page.tsx` → 改为 redirect 到 `/shell/teacher/homework/submissions`
- `/shell/teacher/exams/grading/[submissionId]/page.tsx` → 改为 redirect 到 `/shell/teacher/homework/submissions/[submissionId]`
#### P0-2: 家长域 22 个缺失页面(核心业务)
按业务重要性排序:
1. children子女列表 + 详情)- 家长域核心功能
2. grades成绩查看- 家长最关注
3. homework作业查看
4. attendance考勤查看
5. exams考试结果
6. course-plans课程计划
7. lesson-plans教案查看
8. error-book错题本
9. diagnostic诊断报告
10. leave请假申请
11. learning-path学习路径
12. practice练习记录
13. elective选修课
14. preferences偏好设置
15. trend趋势详情
16. weakness薄弱点详情
17. notifications + settings共享路由
### P1 - 重要功能补全
#### P1-1: 学生域 4 页 mock 数据缺口
- 补齐 mockMyLearningPath
- 补齐 mockElectiveCourseDetail
- 补齐 mockDiagnosticReports
- 补齐 elective 部分字段
#### P1-2: 教师域 15 页功能增强(与 CICD 对齐)
详见 teacher-NeedTodo.md §三.B包括
- exams 富文本编辑器接入exam-edit + new/create
- lesson-plans Tiptap 编辑器 + blocks + AI + 版本历史
- grades 批量录入 + 高级图表 + 成绩单打印
- classes 详情页 widgets + schedule 组件
- attendance 图表 + report-print
- course-plans calendar/form/progress 组件
- error-book 图表组件
- leave 3 个组件 + questions 4 个组件
- schedule-changes grid/rules/auto + practice 图表
- textbooks reader + content-panel
- elective course-list/form/detail
### P2 - 架构优化
#### P2-1: 管理员域契约补齐
- 47 页 schema 未就绪,需后端补齐 GraphQL 契约
- 前端已用 MSW 兜底先行,可继续开发
#### P2-2: 家长域契约补齐
- `myChildren` 契约缺失是核心阻塞
- 需后端补齐家长域相关 GraphQL schema
---
## 四、关键风险
### 4.1 家长域整体缺失(高风险)
- 仅 1/24 页完成,完成率 4.2%
- features/parent/ 目录不存在,需从零建设
- 影响家长端用户体验,无法上线
### 4.2 契约脱节(中风险)
- 管理员域 47/49 页契约 pending
- 家长域核心契约myChildren缺失
- 前端依赖 MSW 兜底,后端就绪前无法真实数据验证
### 4.3 Mock 数据缺口(低风险)
- 学生域 4 页 mock 缺口影响 dev 环境演示
- 家长域仅 1 项 mock新建页面需同步补 mock
---
## 五、建议执行顺序
1. **立即执行 P0-1**:教师域 3 个占位页改为重定向(工作量小,立即见效)
2. **立即执行 P0-2**:家长域 22 个缺失页面(工作量大,需分配专门 AI/开发者)
- 先建立 features/parent/ 目录结构
- 先实现 children 模块(核心功能)
- 逐步实现其他模块
3. **并行执行 P1-1**:学生域 mock 数据补齐(工作量小)
4. **后续执行 P1-2**:教师域功能增强(工作量中等,可分批进行)
5. **后端协同 P2**:契约补齐需后端团队配合
---
## 六、核查文档清单
| 文档 | 路径 | 核查日期 |
| ---------------- | -------------------------------------------- | ---------- |
| 教师域核查报告 | [teacher-NeedTodo.md](./teacher-NeedTodo.md) | 2026-08-04 |
| 学生域核查报告 | [student-NeedTodo.md](./student-NeedTodo.md) | 2026-08-04 |
| 家长域核查报告 | [parent-NeedTodo.md](./parent-NeedTodo.md) | 2026-08-04 |
| 管理员域核查报告 | [admin-NeedTodo.md](./admin-NeedTodo.md) | 2026-08-04 |
| 本汇总报告 | [SUMMARY-NeedTodo.md](./SUMMARY-NeedTodo.md) | 2026-08-04 |
---
**核查完成。四个角色域共 145 个页面142 个已完整实现3 个占位教师域重定向22 个缺失(家长域待建)。**

View File

@@ -1,582 +1,486 @@
# 管理域Admin待完成功能分析
# 管理域Admin待完成功能分析
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\admin\`41 个 page.tsx
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\admin\`1 个 page.tsx
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.4(管理域 24 页B5 批次)
> 分析日期2026-07-24
> 分析方式:逐页面对比 CICD 实现 vs portal-shell 实现,逐个读取 CICD page.tsx + ARCH §9.4 契约
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\admin\`49 个 page.tsx
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.4(管理域 24 页顶层入口B5 批次)
> 核查日期2026-08-04
> 核查方式:逐个读取 49 个 page.tsx + 44 个 features/admin/*-client.tsx + lib/api/admin.ts(973 行) + lib/api/admin-p5.ts(3371 行) + mocks/graphql-data.ts(9867 行),结合 ARCH §9.4 契约判断实现完整性
---
## 〇、方法论与对比基线
1. CICD 单体Next.js App Router + Server Actions + Drizzle作为**功能基线**,反映"老版单体已实现"的管理员功能完整态。
2. portal-shell 作为**目标态**,遵循 ARCH §9.4 的契约与批次规划B524 页)。
2. portal-shell 作为**目标态**,遵循 ARCH §9.4 的契约与批次规划B524 页顶层入口)。
3. CICD admin/layout.tsx 仅为 `<>{children}</>` passthrough无独立侧边栏/权限逻辑(权限由各 page.tsx 调 `requirePermission` 完成)。
4. ARCH §9.4 规划 24 页 = 1 仪表盘 + 23 管理子页portal-shell 仅完成 `/shell/admin/page.tsx`(仪表盘),其余 23 页全部缺失
5. CICD 中存在但 ARCH §9.4 **未规划**的页面course-plans/curriculum-map/elective/questions/lesson-plans/error-book/scheduling/attendance 等 21 页CICD 历史已实现portal-shell 缺失但**不违反 ARCH §9.4**;这些页面是教师域在管理员视角的"全局只读 + 跨班聚合"版本,可作为 B5 后期/补充批次候选。
6. ARCH §9.4 中存在但 CICD **无对应**的页面4 页):`students``teachers``organization``viewports``plugins`,属 ARCH 新规划,需在 portal-shell 中独立设计
4. ARCH §9.4 规划 24 页顶层入口 = 1 仪表盘 + 23 管理子页portal-shell 在此基础上补充了详情/编辑/创建子页与 redirect 入口,实际落地 **49 个 page.tsx**
5. 判断口径(任务约束):
- **✅ 完整实现**page.tsx 引入 `@/features/admin/*-client.tsx` 并渲染client 组件承担业务逻辑,调用 lib/api hooks处理三态或 page.tsx 为有意的 `redirect()` 入口跳转
- **🟡 占位空态**page.tsx 仅用 ListPageShell/FormPageShell/DetailPageShell 直接渲染空态,无 children 业务内容。
- **❌ 缺失**page.tsx 文件不存在。
6. 核查覆盖维度page.tsx 入口 + features/admin/*-client.tsx 业务组件 + lib/api hooks + mocks/graphql-data.ts 兜底数据,四层齐全方视为完整实现。
---
## 一、页面完成度总览
| 状态 | 数量 | 说明 |
| --------------------------------- | ---- | ---------------------------------------------------------------------------------------------- |
| ✅ 已完成且功能完整 | 1 | `/shell/admin/page.tsx`仪表盘4 StatCard + 预警 + AI 用量,已接 `adminDashboard` 真实契约) |
| 全缺失ARCH §9.4 规划内) | 23 | 见 §二,按 ARCH 规划口径的 23 个缺失页面 |
| CICD 已实现但 ARCH §9.4 未规划 | 21 | 见 §五,作为补充批次参考 |
| 🆕 ARCH §9.4 规划但 CICD 无源 | 4 | `students``teachers``organization``viewports`+ `plugins` 已有契约 ✅) |
| 状态 | 数量 | 说明 |
| --------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| ✅ 完整实现(业务页) | 46 | page.tsx 引入对应 `features/admin/*-client.tsx` 并在 `<Suspense>` 中渲染client 组件调用 lib/api hooks处理 loading/error/empty 三态 |
| 整实现(重定向) | 3 | page.tsx 为有意的 `redirect()` 入口跳转classes / scheduling / school 三个模块根路径) |
| 🟡 占位空态 | 0 | 无 |
| ❌ 缺失 | 0 | |
| **合计** | **49** | **管理员域 49/49 page.tsx 全部落地** |
### ✅ 已完成且功能完整1 页)
### ✅ 完整实现46 业务页 + 3 重定向页 = 49 页)
| 模块 | 页面 | 备注 |
| --------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| dashboard | `/shell/admin/page.tsx` | 客户端组件,`useAdminDashboard` hook 接 data-ana `adminDashboard` 真实契约4 StatCard教师/学生/班级/全校平均分)+ 近期预警 + AI 用量;含 loading/error 兜底;引用 ARCH §7.1 / §10 P1-2 |
所有 49 个 page.tsx 均已存在且非占位。46 个业务页统一采用 Server Component 入口 + Suspense 边界 + Client Component 业务的架构3 个重定向页为有意的入口跳转,避免空白入口。
业务页架构样例(以 `/shell/admin/ai-settings/page.tsx` 为例):
```tsx
import { Suspense } from "react";
import { AiSettingsClient } from "@/features/admin/ai-settings/ai-settings-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
export default function AiSettingsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<AiSettingsClient />
</Suspense>
);
}
```
重定向页架构样例(以 `/shell/admin/school/page.tsx` 为例):
```tsx
import { redirect } from "next/navigation";
export default function SchoolAdminIndexPage(): never {
redirect("/shell/admin/school/schools");
}
```
---
## 二、缺失页面清单(按 ARCH §9.4 规划口径23 页)
### 2.1 dashboard 模块(无缺口)
`/shell` 已落地,对应 ARCH §9.4 第 1 行 `admin/dashboard``/shell`(动作为 R契约 ✅ `adminDashboard`)。
### 2.2 users 模块2 页缺失)
#### 2.2.1 `/shell/admin/users`(用户管理列表)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\users\page.tsx`
- **功能描述**:用户列表 + 角色 assignment 入口
- URL 参数:`page``search``role`(搜索/角色筛选 + 分页)
- 并行查询:`getAdminUsers({ page, search, role })` + `getAdminUserRoles()` + `getRoles()`
- 渲染 `AdminUsersView` 客户端组件,传入 users / roleOptions / 分页信息 / assignableRoles
- **技术栈**Server Component + `requirePermission(Permissions.USER_MANAGE)` + `force-dynamic` + `getSearchParam` 工具 + Drizzle data-access
- **ARCHITECTURE.md 契约**:❌ `users` 列表B5未就绪
- **CICD 关键代码片段**
```ts
await requirePermission(Permissions.USER_MANAGE);
const [result, roleOptions, roles] = await Promise.all([
getAdminUsers({ page, search, role }),
getAdminUserRoles(),
getRoles(),
]);
```
#### 2.2.2 `/shell/admin/users/import`(用户批量导入)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\users\import\page.tsx`
- **功能描述**:用户批量导入页面,包含
- 顶部 `UserImportDialog`(导入弹窗,触发上传 + Server Action 解析)
- 操作指引卡片4 步流程)
- 注意事项卡片6 条规则)
- 模板字段说明表5 列name/email/role/phone/inviteCode标注是否必填
- **技术栈**Server Component + `requirePermission(Permissions.USER_MANAGE)` + UI 基础组件Card / Table / Button+ next-intl
- **ARCHITECTURE.md 契约**:❌(未单独列,归 `users` 工单)
- **CICD 关键代码片段**:通过 `<UserImportDialog />` 客户端组件触发导入流程,模板字段固定 5 列。
### 2.3 roles + permissions 模块2 页缺失)
#### 2.3.1 `/shell/admin/roles`(角色管理列表)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\roles\page.tsx`
- **功能描述**:角色列表 + 创建/编辑入口
- 查询 `getRoles()` 拉取所有角色
- 渲染 `RoleManagementView` 客户端组件(含创建按钮、角色卡片/列表)
- **技术栈**Server Component + `requirePermission(Permissions.ROLE_READ)` + Drizzle `rbac/data-access`
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
await requirePermission(Permissions.ROLE_READ)
const roles = await getRoles()
return <RoleManagementView roles={roles} />
```
#### 2.3.2 `/shell/admin/permissions`(权限目录)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\permissions\page.tsx`
- **功能描述**:权限目录页(系统所有权限点列表,按模块分组)
- 查询 `getPermissionRoleCounts()` 获取每个权限点关联的角色数
- 渲染 `PermissionCatalogView` 客户端组件
- **技术栈**Server Component + `requirePermission(Permissions.PERMISSION_READ)` + Drizzle `rbac/data-access-permissions`
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
await requirePermission(Permissions.PERMISSION_READ)
const roleCountsByPermission = await getPermissionRoleCounts()
return <PermissionCatalogView roleCountsByPermission={roleCountsByPermission} />
```
> **附注**CICD 另有 `admin/roles/[id]/page.tsx`(角色详情,含 `RolePermissionMatrix` 权限矩阵 + `RoleDetailEditButton`),但 ARCH §9.4 仅规划 `/shell/admin/roles` 一页,未列详情子页。可作 B5 后期补强。
### 2.4 audit-logs 模块4 页缺失)
#### 2.4.1 `/shell/admin/audit-logs`(审计日志列表 + 导出)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\audit-logs\page.tsx`
- **功能描述**:审计日志列表 + 多维筛选 + CSV 导出
- URL 参数:`page`、`module`、`action`、`status`、`userId`、`startDate`、`endDate`
- 并行查询:`getAuditLogs({...})` + `getAuditModuleOptions()`
- `AuditLogExportButton` 触发 CSV 导出(按筛选条件)
- `SectionErrorBoundary` 错误兜底
- **技术栈**Server Component + `requirePermission(Permissions.AUDIT_LOG_READ)` + Drizzle `audit/data-access` + 类型守卫 `isValidAuditLogStatus`
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
const [result, moduleOptions] = await Promise.all([
getAuditLogs({ page, module, action, status, userId, startDate, endDate }),
getAuditModuleOptions(),
]);
```
#### 2.4.2 `/shell/admin/audit-logs/overview`(审计概览)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\audit-logs\overview\page.tsx`
- **功能描述**:审计概览页(统计/趋势/分布三件套)
- 并行查询:`getAuditOverviewStats()` + `getAuditTrend(7)` + `getDataChangeActionStats()`
- `AuditServiceProvider` 包裹 `adminAuditService` 注入服务上下文
- 渲染 `AuditOverviewView` 客户端组件(统计卡 + 7 天趋势图 + 动作分布饼图)
- **技术栈**Server Component + `requirePermission(Permissions.AUDIT_LOG_READ)` + 服务注入模式
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
const [stats, trend, distribution] = await Promise.all([
getAuditOverviewStats(),
getAuditTrend(7),
getDataChangeActionStats(),
]);
```
#### 2.4.3 `/shell/admin/audit-logs/login-logs`(登录日志)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\audit-logs\login-logs\page.tsx`
- **功能描述**:登录日志列表 + 筛选 + 导出
- URL 参数:`page`、`action`signin/signout/signup、`status`success/failure、`userId`、`startDate`、`endDate`
- 查询 `getLoginLogs({...})`
- `AuditLogExportButton exportType="login"` 触发导出
- 渲染 `LoginLogView` 客户端组件
- **技术栈**Server Component + `requirePermission(Permissions.AUDIT_LOG_READ)` + 类型守卫 `isValidLoginLogAction` / `isValidLoginLogStatus`
- **ARCHITECTURE.md 契约**B5
#### 2.4.4 `/shell/admin/audit-logs/data-changes`(数据变更日志)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\audit-logs\data-changes\page.tsx`
- **功能描述**:数据变更日志列表 + 筛选 + 导出 + 统计
- URL 参数:`page`、`tableName`、`action`create/update/delete、`userId`、`startDate`、`endDate`
- 并行查询:`getDataChangeLogs({...})` + `getDataChangeTableOptions()` + `getDataChangeStats()`
- 渲染 `DataChangeLogView` 客户端组件(含表选项下拉 + 变更统计卡)
- **技术栈**Server Component + `requirePermission(Permissions.AUDIT_LOG_READ)` + 类型守卫 `isValidDataChangeAction`
- **ARCHITECTURE.md 契约**B5
### 2.5 invitation-codes 模块1 页缺失)
#### 2.5.1 `/shell/admin/invitation-codes`(邀请码管理)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\invitation-codes\page.tsx`
- **功能描述**:邀请码列表 + 生成 + 删除
- SSR 查询 `listInvitationCodes(200, true)`200 条 + 含已用)
- 服务端计算 `now = new Date().getTime()` 传给客户端(避免 Client 端 render 阶段调用 `Date.now()`,符合 react-hooks/purity 规则)
- 生成/删除通过 Server Action + `router.refresh()` 触发更新
- 渲染 `InvitationCodesView` 客户端组件
- **技术栈**Server Component + `requirePermission(Permissions.USER_MANAGE)` + Drizzle `invitation-codes/data-access`
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
const codes = await listInvitationCodes(200, true)
const now = new Date().getTime()
return <InvitationCodesView initialCodes={codes} now={now} />
```
### 2.6 school 模块6 页缺失)
#### 2.6.1 `/shell/admin/school`学校首页redirect
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\school\page.tsx`
- **功能描述**:纯重定向页 `redirect("/admin/school/schools")`,无 UI
- **技术栈**`redirect()` from `next/navigation`
- **ARCHITECTURE.md 契约**B5
#### 2.6.2 `/shell/admin/school/schools`(学校列表)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\school\schools\page.tsx`
- **功能描述**:学校列表 + CRUD
- 查询 `getSchools()`
- 渲染 `SchoolsClient` 客户端组件
- `SectionErrorBoundary` 错误兜底
- **技术栈**Server Component + `requirePermission(Permissions.SCHOOL_MANAGE)` + Drizzle `school/data-access`
- **ARCHITECTURE.md 契约**B5
#### 2.6.3 `/shell/admin/school/classes`(班级管理)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\school\classes\page.tsx`
- **功能描述**:班级管理 + 跨数据聚合
- 并行查询:`getAdminClasses()` + `getTeacherOptions()` + `getSchools()` + `getGrades()`
- 渲染 `AdminClassesClient` 客户端组件(含班级 CRUD + 教师/学校/年级下拉)
- **技术栈**Server Component + `requirePermission(Permissions.SCHOOL_MANAGE)` + Drizzle `classes/data-access` + `school/data-access`
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
const [classes, teachers, schools, grades] = await Promise.all([
getAdminClasses(),
getTeacherOptions(),
getSchools(),
getGrades(),
]);
```
> **注**ARCH §9.4 第 7 行将 `admin/classes` 单列 1 页M与 `school/classes` 实际同源,建议合并到 `/shell/admin/school/classes`。
#### 2.6.4 `/shell/admin/school/departments`(部门管理)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\school\departments\page.tsx`
- **功能描述**:部门列表 + CRUD
- 查询 `getDepartments()`
- 渲染 `DepartmentsClient` 客户端组件
- **技术栈**Server Component + `requirePermission(Permissions.SCHOOL_MANAGE)` + Drizzle
- **ARCHITECTURE.md 契约**B5
#### 2.6.5 `/shell/admin/school/academic-year`(学年管理)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\school\academic-year\page.tsx`
- **功能描述**:学年列表 + CRUD
- 查询 `getAcademicYears()`
- 渲染 `AcademicYearClient` 客户端组件
- **技术栈**Server Component + `requirePermission(Permissions.SCHOOL_MANAGE)` + Drizzle
- **ARCHITECTURE.md 契约**B5
#### 2.6.6 `/shell/admin/school/grades`(年级管理)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\school\grades\page.tsx`
- **功能描述**:年级列表 + CRUD + 统计
- 并行查询:`getGrades()` + `getSchools()` + `getStaffOptions()` + `getGradeOverviewStats()`
- 渲染 `GradesClient` 客户端组件(年级 CRUD + 学校/教职工下拉 + 统计概览)
- **技术栈**Server Component + `requirePermission(Permissions.SCHOOL_MANAGE)` + Drizzle
- **ARCHITECTURE.md 契约**B5
> **附注**CICD 另有 `school/grades/insights/page.tsx`(年级洞察,含 `SchoolWideSummaryCard` 全校汇总 + ChipNav 年级筛选 + 作业表 + 班级排名表 + `getSchoolWideGradeSummary(ctx.dataScope)`),属管理员数据分析视角,**ARCH §9.4 未单独规划**,可作 B5 后期补强。
### 2.7 announcements 模块1 页缺失ARCH 口径)
#### 2.7.1 `/shell/admin/announcements`(公告管理列表)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\announcements\page.tsx`
- **功能描述**:公告列表 + 状态筛选
- URL 参数:`status`draft/published/archived
- 查询 `getAdminAnnouncementsPageData(status)`(一次性返回 announcements + grades + classes
- 渲染 `AdminAnnouncementsView` 客户端组件
- **技术栈**Server Component + `requirePermission(Permissions.ANNOUNCEMENT_MANAGE)` + Drizzle `announcements/data-access` + 类型守卫 `isValidStatus`
- **ARCHITECTURE.md 契约**B5
> **附注**CICD 另有 `announcements/[id]/page.tsx`(公告详情,含发布/归档/删除/置顶操作)+ `announcements/[id]/edit/page.tsx`(编辑表单 `AnnouncementForm mode="edit"` + grades 关联),**ARCH §9.4 仅规划 1 页**,建议合并到详情子页或工作流组件。
### 2.8 files 模块1 页缺失)
#### 2.8.1 `/shell/admin/files`(文件管理)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\files\page.tsx`
- **功能描述**:文件列表 + 统计
- 并行查询:`getFileAttachmentsWithFilters({ limit: 200 })` + `getFileStats()`
- 渲染 `AdminFilesView` 客户端组件(含统计卡 + 文件列表)
- **技术栈**Server Component + `requirePermission(Permissions.FILE_READ)` + Drizzle `files/data-access`
- **ARCHITECTURE.md 契约**B5
- **CICD 关键代码片段**
```ts
const [files, stats] = await Promise.all([
getFileAttachmentsWithFilters({ limit: 200 }),
getFileStats(),
]);
```
## 二、已实现页面清单(按模块分组49 页)
### 2.9 ai-settings 模块1 页缺失
> 状态标识:✅ 完整业务实现 / ↪️ 重定向入口 / 🟡 占位 / ❌ 缺失
> 三态规范:所有 ✅ 业务页的 client 组件均处理 loadingSkeleton/ error局部降级/ emptyEmptyState三态ARCH §11.3 DoD
#### 2.9.1 `/shell/admin/ai-settings`AI 配置
### 2.1 dashboard 模块1 页
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\ai-settings\page.tsx`
- **功能描述**AI Provider 配置 + Usage 仪表盘(取代旧 `/settings?tab=ai` 与考试页内嵌 AI 弹窗)
- 权限:`AI_CHAT`(普通用户可访问自己的 private provider管理员额外有 `AI_CONFIGURE`(管理 public provider 与他人 private provider
- 渲染 `AiProviderSettingsCard` 客户端组件(含 isAdmin 开关)
- 管理员额外渲染 `AiUsageDashboard`(用量统计)
- **技术栈**Server Component + `requirePermission(Permissions.AI_CHAT)` + 通过 `ctx.permissions.includes(Permissions.AI_CONFIGURE)` 区分管理员
- **ARCHITECTURE.md 契约**B5
### 2.10 system 模块1 页缺失)
| 页面路径 | 状态 | 实现要点 |
| -------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/shell/admin` | ✅ | "use client" + useAdminDashboard() hook 接 data-ana adminDashboard 真实契约4 StatCard教师/学生/班级/全校平均分)+ 近期预警 + AI 用量;含 loading/error 兜底;引用 ARCH §7.1 / §10 P1-2 |
#### 2.10.1 `/shell/admin/system`(系统设置
### 2.2 ai-settings 模块1 页
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\admin\settings\page.tsx`
- **功能描述**:系统设置页
- 渲染 `AdminSettingsView` 客户端组件(具体配置项由组件内部决定)
- **技术栈**Server Component + `requirePermission(Permissions.SETTINGS_ADMIN)` + Drizzle
- **ARCHITECTURE.md 契约**B5对应 ARCH §9.4 第 13 行 `admin/system` → `/shell/admin/system`
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------ | ---- | ---------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| /shell/admin/ai-settings | ✅ | ai-settings-client.tsx | 522 | useAiProviders / useCreateAiProvider / useUpdateAiProvider / useDeleteAiProvider / useTestAiProvider / useAiUsageDashboard | ❌ schema 未就绪 → MSW 兜底 |
### 2.11 viewports 模块(1缺失ARCH 新建
### 2.3 announcements 模块(3 页)
#### 2.11.1 `/shell/admin/viewports`(视口配置)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------------------ | ---- | ------------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/announcements | ✅ | announcements-list-client.tsx | 469 | useAdminAnnouncements / useDeleteAnnouncement / useArchiveAnnouncement / usePinAnnouncement / usePublishAnnouncement | ❌ → MSW 兜底 |
| /shell/admin/announcements/[id] | ✅ | announcement-detail-client.tsx | 289 | useAdminAnnouncement | ❌ → MSW 兜底 |
| /shell/admin/announcements/[id]/edit | ✅ | announcement-edit-client.tsx | 266 | useAdminAnnouncement / useUpdateAnnouncement | ❌ → MSW 兜底 |
- **CICD 参考实现****无**CICD 未实现
- **功能描述**:对齐 004 §5.4 视口配置BFF/Service 层 viewport 路由策略可视化)
- **技术栈**:未知,需依据 004 §5.4 设计
- **ARCHITECTURE.md 契约**B5
### 2.4 attendance 模块1 页
### 2.12 plugins 模块1 页,契约已就绪 ✅)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ----------------------- | ---- | --------------------------- | ---- | ----------------------------------------------------------------------------------- | ------------- |
| /shell/admin/attendance | ✅ | admin-attendance-client.tsx | 340 | useAdminAttendanceStats / useAdminAttendanceRecords / useAttendanceGradeCorrelation | ❌ → MSW 兜底 |
#### 2.12.1 `/shell/admin/plugins`(插件管理
### 2.5 audit-logs 模块4 页
- **CICD 参考实现****无**CICD 未实现)
- **功能描述**:现有 portal-shell `plugin-manager` 升级整页
- **技术栈**:依据 portal-shell 已有 plugin-manager 演化
- **ARCHITECTURE.md 契约**:✅ `config-service`(已就绪,可优先实现)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------------------ | ---- | -------------------------- | ---- | ----------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/audit-logs | ✅ | audit-logs-list-client.tsx | 461 | useAuditLogs / useAuditModuleOptions / useExportAuditLogs | ❌ → MSW 兜底 |
| /shell/admin/audit-logs/data-changes | ✅ | data-changes-client.tsx | 472 | useDataChangeLogs / useDataChangeTableOptions / useDataChangeStats / useExportDataChanges | ❌ → MSW 兜底 |
| /shell/admin/audit-logs/login-logs | ✅ | login-logs-client.tsx | 325 | useLoginLogs / useExportLoginLogs | ❌ → MSW 兜底 |
| /shell/admin/audit-logs/overview | ✅ | audit-overview-client.tsx | 386 | useAuditOverviewStats / useAuditTrend / useDataChangeActionStats | ❌ → MSW 兜底 |
### 2.13 ARCH §9.4 规划但 CICD 无源的"新建"页面3 页
### 2.6 classes 模块1 页,重定向
#### 2.13.1 `/shell/admin/students`(学生管理)
| 页面路径 | 状态 | 实现要点 |
| -------------------- | ---- | ----------------------------------------------------------------------------------------------- |
| /shell/admin/classes | ↪️ | redirect("/shell/admin/school/classes"):班级管理归属于 school 限界上下文,避免空白入口重复实现 |
- **CICD 参考实现****无**
- **ARCHITECTURE.md 契约**B5
- **建议**:参照 CICD 中 `admin/school/classes` 等管理页设计,作学生维度聚合视图。
### 2.7 course-plans 模块4 页)
#### 2.13.2 `/shell/admin/teachers`(教师管理)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ----------------------------------- | ---- | ----------------------------- | ---- | ------------------------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/course-plans | ✅ | course-plans-list-client.tsx | 339 | useAdminCoursePlans | ❌ → MSW 兜底 |
| /shell/admin/course-plans/create | ✅ | course-plan-create-client.tsx | 415 | useAdminClasses / useCreateCoursePlanItem 系列 | ❌ → MSW 兜底 |
| /shell/admin/course-plans/[id] | ✅ | course-plan-detail-client.tsx | 644 | useAdminCoursePlan / useUpdateCoursePlanItem / useReorderCoursePlanItems / useBulkToggleCoursePlanItems | ❌ → MSW 兜底 |
| /shell/admin/course-plans/[id]/edit | ✅ | course-plan-edit-client.tsx | 562 | useAdminCoursePlan / useUpdateCoursePlanItem 系列 | ❌ → MSW 兜底 |
- **CICD 参考实现****无**
- **ARCHITECTURE.md 契约**B5
- **建议**:参照 CICD 中 `admin/users` + `getStaffOptions()` 演化。
### 2.8 curriculum-map 模块1 页)
#### 2.13.3 `/shell/admin/organization`(组织管理)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| --------------------------- | ---- | ------------------------- | ---- | ------------------------------------------------------ | ------------- |
| /shell/admin/curriculum-map | ✅ | curriculum-map-client.tsx | 256 | useStandardsCoverageHeatmap / useGlobalLessonPlanStats | ❌ → MSW 兜底 |
- **CICD 参考实现****无**
- **ARCHITECTURE.md 契约**B5
- **建议**:合并 `school/departments` + `school/grades` + 师生维度,作组织架构视图。
### 2.9 elective 模块4 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------------- | ---- | -------------------------- | ---- | ----------------------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/elective | ✅ | elective-list-client.tsx | 545 | useAdminElectives / useGetElectiveOverviewStats / useDeleteElective | ❌ → MSW 兜底 |
| /shell/admin/elective/create | ✅ | elective-create-client.tsx | 381 | useAdminCreateElective | ❌ → MSW 兜底 |
| /shell/admin/elective/[id] | ✅ | elective-detail-client.tsx | 385 | useAdminElective | ❌ → MSW 兜底 |
| /shell/admin/elective/[id]/edit | ✅ | elective-edit-client.tsx | 464 | useAdminUpdateElective / useOpenElectiveSelection / useCloseElectiveSelection / useRunElectiveLottery | ❌ → MSW 兜底 |
### 2.10 error-book 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ----------------------- | ---- | --------------------- | ---- | ---------------------------------------------- | ------------- |
| /shell/admin/error-book | ✅ | error-book-client.tsx | 738 | useAdminErrorBookStats / useExportErrorBookCsv | ❌ → MSW 兜底 |
### 2.11 files 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------ | ---- | --------------------- | ---- | ----------------------------------------------------------------------- | ------------- |
| /shell/admin/files | ✅ | files-list-client.tsx | 377 | useFileAttachments / useFileStats / useUploadFile / useBatchDeleteFiles | ❌ → MSW 兜底 |
### 2.12 invitation-codes 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ----------------------------- | ---- | -------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------ | ------------- |
| /shell/admin/invitation-codes | ✅ | invitation-codes-list-client.tsx | 542 | useInvitationCodes / useCreateInvitationCode / useRevokeInvitationCode / useGenerateInvitationCodes / useDeleteInvitationCodes | ❌ → MSW 兜底 |
### 2.13 lesson-plans 模块2 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| --------------------------------------- | ---- | ---------------------------- | ---- | -------------------------------------------- | ------------- |
| /shell/admin/lesson-plans | ✅ | lesson-plans-list-client.tsx | 489 | useAdminLessonPlans | ❌ → MSW 兜底 |
| /shell/admin/lesson-plans/[planId]/view | ✅ | lesson-plan-view-client.tsx | 205 | useAdminLessonPlan / useSoftDeleteLessonPlan | ❌ → MSW 兜底 |
### 2.14 organization 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------- | ---- | ---------------------------- | ---- | ------------------- | ------------- |
| /shell/admin/organization | ✅ | organization-tree-client.tsx | 245 | useOrganizationTree | ❌ → MSW 兜底 |
### 2.15 permissions 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------ | ---- | --------------------------- | ---- | ---------------------------------------- | ------------- |
| /shell/admin/permissions | ✅ | permissions-list-client.tsx | 216 | usePermissions / usePermissionRoleCounts | ❌ → MSW 兜底 |
### 2.16 plugins 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| -------------------- | ---- | ------------------ | ---- | ------------------------------------------- | ---------------------------------- |
| /shell/admin/plugins | ✅ | plugins-client.tsx | 402 | usePluginRegistry / useUpdatePluginRegistry | ✅ schema 已就绪config-service |
### 2.17 questions 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ---------------------- | ---- | ------------------------- | ---- | ------------------------------------ | ------------- |
| /shell/admin/questions | ✅ | questions-list-client.tsx | 537 | useAdminQuestions / useAdminQuestion | ❌ → MSW 兜底 |
### 2.18 roles 模块2 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ----------------------- | ---- | ---------------------- | ---- | --------------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/roles | ✅ | roles-list-client.tsx | 411 | useRoles / useCreateRole / useDeleteRole / useToggleRoleEnabled | ❌ → MSW 兜底 |
| /shell/admin/roles/[id] | ✅ | role-detail-client.tsx | 165 | useRole / useUpdateRole / useDeleteRole / useRolePermissions / useUpdateRolePermissionActions | ❌ → MSW 兜底 |
### 2.19 scheduling 模块4 页,含 1 重定向)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------------- | ---- | ----------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------ | ------------- |
| /shell/admin/scheduling | ↪️ | redirect("/shell/admin/scheduling/changes"):避免空白入口,默认进入变更审批页 | — | — | — |
| /shell/admin/scheduling/auto | ✅ | auto-schedule-client.tsx | 270 | useAutoSchedule | ❌ → MSW 兜底 |
| /shell/admin/scheduling/changes | ✅ | schedule-changes-client.tsx | 471 | useAdminScheduleChanges / useAdminScheduleEntries / useApproveScheduleChange / useRejectScheduleChange | ❌ → MSW 兜底 |
| /shell/admin/scheduling/rules | ✅ | scheduling-rules-client.tsx | 401 | useAdminSchedulingRules / useUpdateSchedulingRules | ❌ → MSW 兜底 |
### 2.20 school 模块7 页,含 1 重定向)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ----------------------------------- | ---- | ------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/school | ↪️ | redirect("/shell/admin/school/schools"):避免空白入口,默认进入学校列表页 | — | — | — |
| /shell/admin/school/academic-year | ✅ | academic-year-client.tsx | 601 | useAcademicYears / useCreateAcademicYear / useUpdateAcademicYear / useDeleteAcademicYear | ❌ → MSW 兜底 |
| /shell/admin/school/classes | ✅ | admin-classes-client.tsx | 598 | useAdminClasses / useTeacherOptions / useStaffOptions / useCreateAdminClass / useUpdateAdminClass / useDeleteAdminClass | ❌ → MSW 兜底 |
| /shell/admin/school/departments | ✅ | departments-client.tsx | 444 | useDepartments / useCreateDepartment / useUpdateDepartment / useDeleteDepartment | ❌ → MSW 兜底 |
| /shell/admin/school/grades | ✅ | grades-client.tsx | 688 | useGrades / useGradeOverviewStats / useAdminCreateGrade / useUpdateGrade / useDeleteGrade | ❌ → MSW 兜底 |
| /shell/admin/school/grades/insights | ✅ | grade-insights-client.tsx | 495 | useGradeOverviewStats 系列 | ❌ → MSW 兜底 |
| /shell/admin/school/schools | ✅ | schools-client.tsx | 509 | useSchools / useCreateSchool / useAdminUpdateSchool / useDeleteSchool | ❌ → MSW 兜底 |
### 2.21 students 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| --------------------- | ---- | ------------------------ | ---- | ---------------- | ------------- |
| /shell/admin/students | ✅ | students-list-client.tsx | 387 | useAdminStudents | ❌ → MSW 兜底 |
### 2.22 system 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------- | ---- | -------------------------- | ---- | ------------------------------------------- | ------------- |
| /shell/admin/system | ✅ | system-settings-client.tsx | 566 | useSystemSettings / useUpdateSystemSettings | ❌ → MSW 兜底 |
### 2.23 teachers 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| --------------------- | ---- | ------------------------ | ---- | ---------------- | ------------- |
| /shell/admin/teachers | ✅ | teachers-list-client.tsx | 269 | useAdminTeachers | ❌ → MSW 兜底 |
### 2.24 users 模块3 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ------------------------- | ---- | ----------------------- | ---- | ------------------------------------------------------------------------------------------------------- | ------------- |
| /shell/admin/users | ✅ | users-list-client.tsx | 538 | useUsers / useUpdateUserStatus / useUpdateUserRole / useDeleteUser / useAssignUserRoles / useUpdateUser | ❌ → MSW 兜底 |
| /shell/admin/users/import | ✅ | users-import-client.tsx | 414 | useImportUsers | ❌ → MSW 兜底 |
| /shell/admin/users/[id] | ✅ | user-detail-client.tsx | 404 | useUser / useUpdateUser / useAssignUserRoles | ❌ → MSW 兜底 |
### 2.25 viewports 模块1 页)
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
| ---------------------- | ---- | -------------------- | ---- | -------------------------------- | ------------- |
| /shell/admin/viewports | ✅ | viewports-client.tsx | 383 | useViewports / useUpdateViewport | ❌ → MSW 兜底 |
---
## 三、按 ARCH §9.4 模块汇总
| 模块 | ARCH 规划页数 | portal-shell 已完成 | 缺失 | 契约状态 |
| ---------------- | ---------------------------------------------------------------- | ------------------- | ------------------ | ------------------- |
| dashboard | 1 | 1 | 0 | ✅ `adminDashboard` |
| users | 1+1 import 子页 | 0 | 1+1 子页) | ❌ |
| roles | 1 | 0 | 1 | ❌ |
| permissions | 1 | 0 | 1 | ❌ |
| audit-logs | 4(含 overview/login/data-changes | 0 | 4 | ❌ |
| invitation-codes | 1 | 0 | 1 | ❌ |
| school | 6schools/classes/departments/academic-year/grades + redirect | 0 | 6 | ❌ |
| classes | 1与 school/classes 同源) | 0 | 0合并到 school | ❌ |
| students | 1 | 0 | 1 | ❌ |
| teachers | 1 | 0 | 1 | ❌ |
| organization | 1 | 0 | 1 | ❌ |
| announcements | 1 | 0 | 1 | ❌ |
| files | 1 | 0 | 1 | ❌ |
| ai-settings | 1 | 0 | 1 | ❌ |
| system | 1 | 0 | 1 | ❌ |
| viewports | 1 | 0 | 1 | ❌ |
| plugins | 1 | 0 | 1 | ✅ `config-service` |
| **合计** | **~24** | **1** | **~23** | **2 ✅ / ~22 ❌** |
| 模块 | ARCH §9.4 规划页数 | portal-shell 已实现 | 缺失 | 契约状态 |
| ---------------- | --------------------------------- | --------------------- | ----- | ---------------------------- |
| dashboard | 1 | 1 | 0 | ✅ adminDashboard |
| users | 1+1 import +1 详情 | 3 | 0 | ❌ → MSW 兜底 |
| roles | 1+1 详情) | 2 | 0 | ❌ → MSW 兜底 |
| permissions | 1 | 1 | 0 | ❌ → MSW 兜底 |
| audit-logs | 4 | 4 | 0 | ❌ → MSW 兜底 |
| invitation-codes | 1 | 1 | 0 | ❌ → MSW 兜底 |
| school | 6+redirect | 7+grades/insights | 0 | ❌ → MSW 兜底 |
| classes | 1与 school/classes 同源) | 1redirect | 0 | — |
| students | 1 | 1 | 0 | ❌ → MSW 兜底 |
| teachers | 1 | 1 | 0 | ❌ → MSW 兜底 |
| organization | 1 | 1 | 0 | ❌ → MSW 兜底 |
| announcements | 1+2 子页) | 3 | 0 | ❌ → MSW 兜底 |
| files | 1 | 1 | 0 | ❌ → MSW 兜底 |
| ai-settings | 1 | 1 | 0 | ❌ → MSW 兜底 |
| system | 1 | 1 | 0 | ❌ → MSW 兜底 |
| viewports | 1 | 1 | 0 | ❌ → MSW 兜底 |
| plugins | 1 | 1 | 0 | ✅ config-service |
| course-plans | §四补充4 页) | 4 | 0 | ❌ → MSW 兜底 |
| curriculum-map | §四补充1 页) | 1 | 0 | ❌ → MSW 兜底 |
| elective | §四补充4 页) | 4 | 0 | ❌ → MSW 兜底 |
| questions | §四补充1 页) | 1 | 0 | ❌ → MSW 兜底 |
| lesson-plans | §四补充2 页) | 2 | 0 | ❌ → MSW 兜底 |
| error-book | §四补充1 页) | 1 | 0 | ❌ → MSW 兜底 |
| scheduling | §四补充3+redirect | 4 | 0 | ❌ → MSW 兜底 |
| attendance | §四补充1 页) | 1 | 0 | ❌ → MSW 兜底 |
| **合计** | **~24顶层+ ~25子页/补充)** | **49** | **0** | **2 ✅ / 47 ❌MSW 兜底)** |
> **批次**:全部 B5;契约就绪优先级:`plugins`> `dashboard` ✅(已完成)> 其余 ❌ 需 MSW 兜底先行
> **批次**:全部 B5(含 §四补充批次,已全部落地);契约就绪:plugins ✅ + dashboard ✅;其余 47 页 schema 未就绪,前端用 MSW 兜底先行(@contract-pending 标注)
---
## 四、CICD 中存在但 ARCH §9.4 未规划的管理员页面(21 页B5 后期候选
## 四、CICD 中存在但 ARCH §9.4 未规划的管理员页面(已全部实现
> 这些页面在 CICD 中属于"管理员视角的跨班聚合/全局只读"功能是教师域功能在管理员层级的复用。ARCH §9.4 未将其纳入 24 页规划,但作为完整管理员工作台仍具迁移价值。建议作为 B5 末或 B6 补充批次候选,按需取用
> 这些页面在 CICD 中属于"管理员视角的跨班聚合/全局只读"功能是教师域功能在管理员层级的复用。ARCH §9.4 未将其纳入 24 页顶层规划,但 portal-shell 已全部实现(含详情/编辑/创建子页),共 25 页
### 4.1 course-plans 模块4 页)
### 4.1 course-plans 模块4 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| --------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/course-plans/page.tsx` | 课程计划列表admin 全局视角,`scope.isAdmin=true` | Server Component + `requirePermission(Permissions.COURSE_PLAN_READ)` + `getCoursePlans({ status }, { isAdmin: true })` |
| `admin/course-plans/create/page.tsx` | 创建课程计划 | Server Component + `requirePermission(Permissions.COURSE_PLAN_MANAGE)` + 并行查询 classes/subjects/teachers/academicYears + `CoursePlanForm mode="create"` |
| `admin/course-plans/[id]/page.tsx` | 课程计划详情 | Server Component + `getCoursePlanById(id, { userId, isAdmin: true })` + `CoursePlanDetail` |
| `admin/course-plans/[id]/edit/page.tsx` | 编辑课程计划 | Server Component + `requirePermission(Permissions.COURSE_PLAN_MANAGE)` + `CoursePlanForm mode="edit"` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ----------------------------------- | ---- | ----------------------------- | ---- |
| /shell/admin/course-plans | ✅ | course-plans-list-client.tsx | 339 |
| /shell/admin/course-plans/create | ✅ | course-plan-create-client.tsx | 415 |
| /shell/admin/course-plans/[id] | ✅ | course-plan-detail-client.tsx | 644 |
| /shell/admin/course-plans/[id]/edit | ✅ | course-plan-edit-client.tsx | 562 |
### 4.2 curriculum-map 模块1 页)
### 4.2 curriculum-map 模块1 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| ------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/curriculum-map/page.tsx` | 课程地图(标准覆盖热图 + 5 张统计卡:教师/教案/已发布/已提交/已关联标准数) | Server Component + `requirePermission(Permissions.LESSON_PLAN_READ)` + `getStandardsCoverageHeatmapAction()` + `getGlobalLessonPlanStatsAction()` + `CurriculumMapView` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| --------------------------- | ---- | ------------------------- | ---- |
| /shell/admin/curriculum-map | ✅ | curriculum-map-client.tsx | 256 |
### 4.3 elective 模块4 页)
### 4.3 elective 模块4 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| ----------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `admin/elective/page.tsx` | 选修课列表(`StatsCardsLoader` + `CourseListLoader` 双 Suspense 流式) | Server Component + `requirePermission(Permissions.ELECTIVE_READ)` + `ElectivePageLayout` |
| `admin/elective/create/page.tsx` | 创建选修课 | Server Component + `getSubjectOptions()/getGrades()/getStaffOptions()` + `ElectiveCourseForm mode="create"` |
| `admin/elective/[id]/page.tsx` | 选修课详情(含 `getCourseSelections` 选课记录) | Server Component + `requirePermission(Permissions.ELECTIVE_READ)` + `ElectiveCourseDetail` |
| `admin/elective/[id]/edit/page.tsx` | 编辑选修课 | Server Component + `ElectiveCourseForm mode="edit"` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ------------------------------- | ---- | -------------------------- | ---- |
| /shell/admin/elective | ✅ | elective-list-client.tsx | 545 |
| /shell/admin/elective/create | ✅ | elective-create-client.tsx | 381 |
| /shell/admin/elective/[id] | ✅ | elective-detail-client.tsx | 385 |
| /shell/admin/elective/[id]/edit | ✅ | elective-edit-client.tsx | 464 |
### 4.4 questions 模块1 页)
### 4.4 questions 模块1 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| -------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/questions/page.tsx` | 题库管理(筛选 + 导入导出 + 创建) | Server Component + `requirePermission(Permissions.QUESTION_READ)` + `getQuestions({...})` + `QuestionFilters`Suspense + `QuestionBankResultsClient` + `ImportExportButtons` + `CreateQuestionButton` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ---------------------- | ---- | ------------------------- | ---- |
| /shell/admin/questions | ✅ | questions-list-client.tsx | 537 |
### 4.5 lesson-plans 模块2 页)
### 4.5 lesson-plans 模块2 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/lesson-plans/page.tsx` | 教案列表4 统计卡:总数/已发布/草稿/已归档 + `LessonPlanList viewMode="admin"` + `LessonPlanProviderSetup` 注入 ADMIN_ROLE_CONFIG | Server Component + `requirePermission(Permissions.LESSON_PLAN_READ)` + `getLessonPlans({}, ctx.dataScope, ctx.userId)` + `getLessonPlanStats()` |
| `admin/lesson-plans/[planId]/view/page.tsx` | 教案只读视图(含 textbook/chapter 标题解析) | Server Component + `getLessonPlanById(planId, ctx.userId)` + `getTextbookById/getChaptersByTextbookId` 并行 + `LessonPlanReadonlyView` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| --------------------------------------- | ---- | ---------------------------- | ---- |
| /shell/admin/lesson-plans | ✅ | lesson-plans-list-client.tsx | 489 |
| /shell/admin/lesson-plans/[planId]/view | ✅ | lesson-plan-view-client.tsx | 205 |
### 4.6 error-book 模块1 页)
### 4.6 error-book 模块1 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/error-book/page.tsx` | 错题本分析5 统计卡 + 学科 Tab + 学科分布图 + 章节/知识点薄弱度并排 + Top 50 学生 + Top 10 高频错题;限 500 学生 + DataScope 校验 `type === "all"` | Server Component + `requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ)` + 多 data-access 并行 + `SubjectTabs/StatsGrid/SubjectDistributionChart/ChapterWeaknessChart/KnowledgePointWeaknessChart/GroupedStudentErrorTable/TopWrongQuestions` + `WidgetBoundary` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ----------------------- | ---- | --------------------- | ---- |
| /shell/admin/error-book | ✅ | error-book-client.tsx | 738 |
### 4.7 scheduling 模块3 页)
### 4.7 scheduling 模块3 页 + redirect已实现
| CICD 路径 | 功能 | 技术栈 |
| ----------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/scheduling/auto/page.tsx` | 自动排课(`AutoSchedulePanel` + 跳转 rules 配置入口) | Server Component + `requirePermission(Permissions.SCHEDULE_AUTO)` + `getAdminClassesForScheduling()` |
| `admin/scheduling/changes/page.tsx` | 排课变更审批(变更列表 + 冲突检测 + 课表网格) | Server Component + `requirePermission(Permissions.SCHEDULE_ADJUST)` + 并行 `getAdminClassesForScheduling/getScheduleChanges/getScheduleEntriesForAdmin` + `ScheduleChangeList canApprove` + `ScheduleConflictsView` + `ScheduleGridView` |
| `admin/scheduling/rules/page.tsx` | 排课规则配置 | Server Component + `requirePermission(Permissions.SCHEDULE_ADJUST)` + `getSchedulingRules()` + `SchedulingRulesForm` |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ------------------------------- | ---- | ------------------------------ | ---- |
| /shell/admin/scheduling | ↪️ | redirect → /scheduling/changes | — |
| /shell/admin/scheduling/auto | ✅ | auto-schedule-client.tsx | 270 |
| /shell/admin/scheduling/changes | ✅ | schedule-changes-client.tsx | 471 |
| /shell/admin/scheduling/rules | ✅ | scheduling-rules-client.tsx | 401 |
### 4.8 attendance 模块1 页)
### 4.8 attendance 模块1 页,已实现
| CICD 路径 | 功能 | 技术栈 |
| --------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/attendance/page.tsx` | 考勤管理7 统计卡 + 班级对比卡 + 考勤-成绩关联分析卡 + 筛选器) | Server Component + `requirePermission(Permissions.ATTENDANCE_READ)` + 并行 `getAttendanceRecords/getAttendanceStats/getGrades/getClassComparison/getAttendanceGradeCorrelation` + `AdminAttendanceFilters`(含 class/status/date 筛选) |
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ----------------------- | ---- | --------------------------- | ---- |
| /shell/admin/attendance | ✅ | admin-attendance-client.tsx | 340 |
> **附注**CICD `admin/attendance/admin-attendance-filters.tsx` 是页内私有 Client Component封装 `FilterBar + Select + Input`,使用 `ATTENDANCE_STATUS_OPTIONS` 常量。迁移时此类"页内 filters 组件"模式应保持。
### 4.9 announcements 详情/编辑2 页,已实现)
### 4.9 announcements 详情/编辑2 页)
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ------------------------------------ | ---- | ------------------------------ | ---- |
| /shell/admin/announcements/[id] | ✅ | announcement-detail-client.tsx | 289 |
| /shell/admin/announcements/[id]/edit | ✅ | announcement-edit-client.tsx | 266 |
| CICD 路径 | 功能 | 技术栈 |
| ---------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/announcements/[id]/page.tsx` | 公告详情(`AnnouncementDetail canManage` + 跳转 edit + 发布/归档/删除/置顶操作入口) | Server Component + `requirePermission(Permissions.ANNOUNCEMENT_MANAGE)` + `getAdminAnnouncementDetailPageData(id)` + `AnnouncementsServiceProvider` |
| `admin/announcements/[id]/edit/page.tsx` | 公告编辑(`AnnouncementForm mode="edit"` + grades 关联) | Server Component + `requirePermission(Permissions.ANNOUNCEMENT_MANAGE)` + `getEditAnnouncementPageData(id)` |
### 4.10 users/import + users/[id] 子页2 页,已实现)
### 4.10 users/import 子页1 页)
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ------------------------- | ---- | ----------------------- | ---- |
| /shell/admin/users/import | ✅ | users-import-client.tsx | 414 |
| /shell/admin/users/[id] | ✅ | user-detail-client.tsx | 404 |
已在 §2.2.2 列入 ARCH 规划口径(与 `users` 工单合并),此处不重复。
### 4.11 roles/[id] 详情1 页,已实现)
### 4.11 roles/[id] 详情1 页)
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ----------------------- | ---- | ---------------------- | ---- |
| /shell/admin/roles/[id] | ✅ | role-detail-client.tsx | 165 |
| CICD 路径 | 功能 | 技术栈 |
| --------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `admin/roles/[id]/page.tsx` | 角色详情(`RolePermissionMatrix` 权限矩阵 + `RoleDetailEditButton` + admin 角色锁定提示卡) | Server Component + `requirePermission(Permissions.ROLE_READ)` + `getRoleById(id)` + `notFound()` 兜底 + admin 角色强制锁定isLocked |
### 4.12 school/grades/insights1 页,已实现)
### 4.12 school/grades/insights1 页)
已在 §2.6 附注中提及,不重复。
| portal-shell 路径 | 状态 | client 组件 | 行数 |
| ----------------------------------- | ---- | ------------------------- | ---- |
| /shell/admin/school/grades/insights | ✅ | grade-insights-client.tsx | 495 |
---
## 五、迁移注意事项
### 5.1 架构迁移要点
### 5.1 架构迁移要点(已完成)
1. **管理员权限校验**:所有 23 缺失页面均需 `requirePermission(Permissions.XXX)` 在 Server Component 入口校验。CICD 模式可直接复用,但需对齐 portal-shell 的 auth-guard004 §5 鉴权链路)
2. **DataScope 上下文**`school/grades/insights`、`error-book`、`attendance` 等页面依赖 `ctx.dataScope`all / class_taught / class_members / childrenportal-shell 在 BFF 层补齐数据范围上下文。
3. **Server Action + router.refresh 模式**`invitation-codes`、`announcements/[id]/edit` 等使用此模式portal-shell 需保持等价机制BFF API + 客户端 refetch
4. **导出功能**`audit-logs` 三页 + `questions` 导入导出按钮,需后端提供 CSV 导出端点。
5. **服务注入**`audit-logs/overview` 使用 `AuditServiceProvider + adminAuditService` 模式portal-shell 应在 BFF/Service 层保留服务抽象
1. **管理员权限校验**portal-shell 采用 Client Component + BFF 模式,权限校验由 route-permissions.ts 在路由层 EXACT/PREFIX 表登记(如 /shell/admin/* → ["admin"]),与 CICD 的 Server Component + requirePermission 模式不同但等价
2. **DataScope 上下文**portal-shell 在 BFF 层注入数据范围上下文client 组件通过 lib/api hooks 消费,无需 page.tsx 显式传递
3. **Server Action + router.refresh 模式**portal-shell 改用 Apollo Client mutation + useQuery refetch / notify 通知,等价于 CICD 的 Server Action + router.refresh()
4. **导出功能**audit-logs 三页 + error-book 的 CSV 导出,已通过 useExportAuditLogs / useExportLoginLogs / useExportDataChanges / useExportErrorBookCsv hooks 封装,等待后端契约就绪后接真实端点。
5. **服务注入**audit-logs/overview 在 portal-shell 中改为 client 组件直接调用 useAuditOverviewStats / useAuditTrend / useDataChangeActionStats 三个 hook 并行查询,等价于 CICD 的 AuditServiceProvider + adminAuditService 模式。
### 5.2 模块复杂度风险
### 5.2 模块复杂度与契约风险
| 模块 | 风险点 | 缓解建议 |
| ----------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| **audit-logs**4 页) | 审计核心,多维筛选 + 导出 + 概览统计 + 数据变更统计 | 后端契约工单优先;前端可 MSW 先行 4 页 UI |
| **school**6 页) | 体系复杂6 子页 + 跨表关联schools/classes/departments/grades/academic-year/staff | 按 schools → grades → classes → departments → academic-year 顺序迁移insights 作 B5 末 |
| **scheduling**3 页,§四补充批次) | 涉及自动排课算法 + 冲突检测 + 审批流 | 算法侧需独立服务/工单;前端先做 changes 审批流auto/rules 后置 |
| **error-book**(§四补充批次) | 5 统计卡 + 4 图表 + Top 50 学生 + Top 10 错题;限 500 学生性能保护 | 复用教师域 error-book 组件,仅换 data-access 为 admin scope图表组件应已存在 |
| **attendance**(§四补充批次) | 7 统计 + 班级对比 + 考勤-成绩关联分析 | 后端需提供 `getClassComparison` + `getAttendanceGradeCorrelation` 两个分析端点 |
| **curriculum-map**(§四补充批次) | 标准覆盖热图 + 5 张统计卡 + `getStandardsCoverageHeatmapAction` Server Action | 依赖 lesson-preparation 模块的 actions-analytics需先迁移教师域 lesson-plans |
| **ai-settings** | 双权限AI_CHAT + AI_CONFIGURE+ Provider 配置 + Usage 仪表盘 | 后端需提供 provider CRUD + usage 聚合端点 |
| 模块 | 风险点 | 当前缓解 | 待办 |
| ---------------------- | --------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| audit-logs4 页) | 审计核心,多维筛选 + 导出 + 概览统计 + 数据变更统计 | 前端 4 页 UI 已完成(共 1644 行 client 代码MSW 兜底 | 后端契约工单auditLogs / loginLogs / dataChangeLogs / auditOverviewStats / 导出端点 |
| school7 页) | 体系复杂6 业务子页 + 1 redirect跨表关联 | 前端 7 页 UI 已完成(共 3335 行 client 代码MSW 兜底 | 后端契约工单schools / departments / academicYears / grades / adminClasses / gradeOverviewStats |
| scheduling4 页) | 自动排课算法 + 冲突检测 + 审批流 | 前端 4 页 UI 已完成(共 1142 行 client 代码MSW 兜底 | 后端契约工单autoSchedule / scheduleChanges / scheduleEntries / schedulingRules + 排课算法服务 |
| error-book1 页) | 5 统计卡 + 4 图表 + Top 50 学生 + Top 10 错题 | 前端 1 页 UI 已完成738 行 client 代码最复杂单页MSW 兜底 | 后端契约工单adminErrorBookStats + CSV 导出端点 |
| attendance1 页) | 7 统计 + 班级对比 + 考勤-成绩关联分析 | 前端 1 页 UI 已完成340 行 client 代码MSW 兜底 | 后端契约工单adminAttendanceStats / adminAttendanceRecords / attendanceGradeCorrelation |
| curriculum-map1 页) | 标准覆盖热图 + 5 张统计卡 | 前端 1 页 UI 已完成256 行 client 代码MSW 兜底 | 后端契约工单standardsCoverageHeatmap / globalLessonPlanStats |
| ai-settings1 页) | 双权限 + Provider 配置 + Usage 仪表盘 | 前端 1 页 UI 已完成522 行 client 代码MSW 兜底 | 后端契约工单aiProviders / aiUsageDashboard + Provider CRUD 端点 |
| users3 页) | 用户列表 + 批量导入 + 详情,关联角色分配 | 前端 3 页 UI 已完成(共 1356 行 client 代码MSW 兜底 | 后端契约工单users 列表 + importUsers mutation |
| course-plans4 页) | 课程计划 CRUD + 子项 reorder + 批量切换 | 前端 4 页 UI 已完成(共 1960 行 client 代码MSW 兜底 | 后端契约工单adminCoursePlans / adminCoursePlan + 子项 CRUD mutations |
| elective4 页) | 选修课 CRUD + 抽签 + 选课开关 | 前端 4 页 UI 已完成(共 1775 行 client 代码MSW 兜底 | 后端契约工单adminElectives / adminElective + 抽签 mutation |
### 5.3 契约就绪优先级建议
### 5.3 契约就绪优先级(用于后端工单排期)
按 ARCH §9.4 节奏原则(契约就绪页先行,❌ 页用 MSW 先上 UI
前端 49 页已全部落地,瓶颈在后端 GraphQL schema 就绪。按业务价值与依赖关系排期
1. **优先级 1契约 ✅,可立即实现真实查询)**
- `/shell/admin/plugins`(✅ `config-service`
- `/shell/admin`(已完成)
2. **优先级 2高价值 + 中等复杂度,需后端契约工单)**
- `/shell/admin/users` + `/shell/admin/users/import`(用户管理 + 批量导入)
- `/shell/admin/roles` + `/shell/admin/permissions`RBAC 核心)
- `/shell/admin/invitation-codes`(与 users 共享 USER_MANAGE 权限)
- `/shell/admin/announcements`(公告管理)
- `/shell/admin/files`(文件管理)
3. **优先级 3高复杂度建议 MSW 先行)**
- `/shell/admin/audit-logs/*`4 页,审计核心,多维筛选 + 导出)
- `/shell/admin/school/*`6 页,体系复杂)
4. **优先级 4ARCH 新建,需独立设计)**
- `/shell/admin/students`、`/shell/admin/teachers`、`/shell/admin/organization`、`/shell/admin/viewports`、`/shell/admin/system`
5. **优先级 5§四补充批次B5 末或 B6**
- course-plans/curriculum-map/elective/questions/lesson-plans/error-book/scheduling/attendance 共 16 页
1. **优先级 1契约 ✅,已完成真实查询)**/shell/adminadminDashboard ✅)+ /shell/admin/pluginsconfig-service ✅)
2. **优先级 2高价值 RBAC 核心,需后端契约工单)**users3 页)+ roles2 页)+ permissions1 页)+ invitation-codes1 页)= 7 页后端契约users / roles / permissions / permissionRoleCounts / invitationCodes + 相关 mutations
3. **优先级 3高频运营功能需后端契约工单**announcements3 页)+ files1 页)+ ai-settings1 页)+ system1 页)= 6 页后端契约adminAnnouncements / fileAttachments / aiProviders / aiUsageDashboard / systemSettings
4. **优先级 4审计与组织管理高复杂度**audit-logs4 页)+ school7 页)+ organization1 页)+ students1 页)+ teachers1 页)+ viewports1 页)= 15 页;后端契约:审计 4 类查询 + 导出端点school 6 类实体 CRUDorganizationTree / adminStudents / adminTeachers / viewports
5. **优先级 5§四补充批次教师域聚合视图**course-plans4 页)+ curriculum-map1 页)+ elective4 页)+ questions1 页)+ lesson-plans2 页)+ error-book1 页)+ scheduling4 页)+ attendance1 页)= 18 页;后端契约:均为教师域聚合查询在 admin scope 下的复用
### 5.4 CICD admin/layout.tsx 迁移说明
CICD `admin/layout.tsx``return <>{children}</>`**无独立侧边栏/权限逻辑**(侧边栏由 `(dashboard)/layout.tsx` 统一处理,权限由各 page 调 `requirePermission`)。
CICD admin/layout.tsx 仅 return <>{children}</>**无独立侧边栏/权限逻辑**(侧边栏由 (dashboard)/layout.tsx 统一处理,权限由各 page 调 requirePermission
portal-shell 同样无需在 `shell/admin/layout.tsx` 实现额外逻辑,保持 passthrough 即可;侧边栏与权限校验由 `shell/layout.tsx` 与各 page.tsx 分担。
portal-shell 同样无需在 shell/admin/layout.tsx 实现额外逻辑,保持 passthrough 即可;侧边栏与权限校验由 shell/layout.tsx 与 route-permissions.ts 分担。
### 5.5 三层支撑完整度核查
| 支撑层 | 文件 | 行数 | 覆盖情况 |
| --------------- | ---------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| page.tsx 入口 | src/app/shell/admin/**/page.tsx | 49 文件 | 49/49 全部存在46 业务页 + 3 redirect 页 |
| client 业务组件 | src/features/admin/**/*-client.tsx | 44 文件 | 44/44 全部实现165-738 行/文件,均调用 lib/api hooks处理三态 |
| lib/api hooks | src/lib/api/admin.ts + admin-p5.ts | 973 + 3371 = 4344 行 | 全覆盖users/roles/permissions/audit-logs/invitation-codes/school/announcements/files/ai-settings/system/viewports/students/teachers/organization/course-plans/curriculum-map/elective/questions/lesson-plans/error-book/scheduling/attendance/plugins |
| mocks 兜底数据 | src/mocks/graphql-data.ts | 9867 行 | 全覆盖mockAdminDashboard / mockUsers / mockAdminPermissions / mockSchools / mockDepartments / mockAcademicYears / mockAdminGrades / mockAdminClasses / mockAdminAnnouncements / mockInvitationCodes / mockFileAttachments / mockAiProviders / mockSystemSettings / mockViewports / mockAdminStudents / mockAdminTeachers / mockOrganizationTree / mockAdminCoursePlans / mockAdminLessonPlans / mockAdminElectives / mockAdminQuestions / mockAdminScheduleChanges / mockSchedulingRules / mockAdminAttendanceRecords / mockLoginLogs / mockDataChangeLogs 等 |
---
## 六、附录CICD admin/page.tsx 完整清单41 个)
## 六、附录CICD admin/page.tsx 完整清单41 个)+ ARCH 新建5 个)= 46 项对照
| # | CICD 路径 | ARCH §9.4 归属 | 缺失/已有 |
| --- | ------------------------------------------- | ----------------------------- | ------------------------------------------- |
| 1 | `admin/dashboard/page.tsx` | §9.4 第 1 行 ✅ | portal-shell `/shell/admin/page.tsx` 已完成 |
| 2 | `admin/users/page.tsx` | §9.4 第 2 行 ❌ | 缺失 |
| 3 | `admin/users/import/page.tsx` | §9.4 第 2 行 ❌(合并) | 缺失 |
| 4 | `admin/roles/page.tsx` | §9.4 第 3 行 ❌ | 缺失 |
| 5 | `admin/roles/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 6 | `admin/permissions/page.tsx` | §9.4 第 3 行 ❌ | 缺失 |
| 7 | `admin/audit-logs/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
| 8 | `admin/audit-logs/overview/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
| 9 | `admin/audit-logs/login-logs/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
| 10 | `admin/audit-logs/data-changes/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
| 11 | `admin/invitation-codes/page.tsx` | §9.4 第 5 行 ❌ | 缺失 |
| 12 | `admin/school/page.tsx`redirect | §9.4 第 6 行 ❌ | 缺失 |
| 13 | `admin/school/schools/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
| 14 | `admin/school/classes/page.tsx` | §9.4 第 6/7 行 ❌ | 缺失 |
| 15 | `admin/school/departments/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
| 16 | `admin/school/academic-year/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
| 17 | `admin/school/grades/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
| 18 | `admin/school/grades/insights/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 19 | `admin/announcements/page.tsx` | §9.4 第 10 行 ❌ | 缺失 |
| 20 | `admin/announcements/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 21 | `admin/announcements/[id]/edit/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 22 | `admin/files/page.tsx` | §9.4 第 11 行 ❌ | 缺失 |
| 23 | `admin/ai-settings/page.tsx` | §9.4 第 12 行 ❌ | 缺失 |
| 24 | `admin/settings/page.tsx` | §9.4 第 13 行 ❌(→ /system | 缺失 |
| 25 | `admin/course-plans/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 26 | `admin/course-plans/create/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 27 | `admin/course-plans/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 28 | `admin/course-plans/[id]/edit/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 29 | `admin/curriculum-map/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 30 | `admin/elective/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划 |
| 31 | `admin/elective/create/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 32 | `admin/elective/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 33 | `admin/elective/[id]/edit/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 34 | `admin/questions/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 35 | `admin/lesson-plans/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 36 | `admin/lesson-plans/[planId]/view/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 37 | `admin/error-book/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 38 | `admin/scheduling/auto/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 39 | `admin/scheduling/changes/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 40 | `admin/scheduling/rules/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| 41 | `admin/attendance/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
| # | CICD 路径 | ARCH §9.4 归属 | portal-shell 状态2026-08-04 |
| --- | ----------------------------------------- | ----------------------------- | --------------------------------------------------------------- |
| 1 | admin/dashboard/page.tsx | §9.4 第 1 行 ✅ | /shell/admin/page.tsx(真实契约 adminDashboard |
| 2 | admin/users/page.tsx | §9.4 第 2 行 ❌ | ✅ /shell/admin/users/page.tsxMSW 兜底) |
| 3 | admin/users/import/page.tsx | §9.4 第 2 行 ❌(合并) | ✅ /shell/admin/users/import/page.tsxMSW 兜底) |
| 4 | admin/roles/page.tsx | §9.4 第 3 行 ❌ | ✅ /shell/admin/roles/page.tsxMSW 兜底) |
| 5 | admin/roles/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/roles/[id]/page.tsxMSW 兜底) |
| 6 | admin/permissions/page.tsx | §9.4 第 3 行 ❌ | ✅ /shell/admin/permissions/page.tsxMSW 兜底) |
| 7 | admin/audit-logs/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/page.tsxMSW 兜底) |
| 8 | admin/audit-logs/overview/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/overview/page.tsxMSW 兜底) |
| 9 | admin/audit-logs/login-logs/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/login-logs/page.tsxMSW 兜底) |
| 10 | admin/audit-logs/data-changes/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/data-changes/page.tsxMSW 兜底) |
| 11 | admin/invitation-codes/page.tsx | §9.4 第 5 行 ❌ | ✅ /shell/admin/invitation-codes/page.tsxMSW 兜底) |
| 12 | admin/school/page.tsxredirect | §9.4 第 6 行 ❌ | ↪️ /shell/admin/school/page.tsx → /school/schools |
| 13 | admin/school/schools/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/schools/page.tsxMSW 兜底) |
| 14 | admin/school/classes/page.tsx | §9.4 第 6/7 行 ❌ | ✅ /shell/admin/school/classes/page.tsxMSW 兜底) |
| 15 | admin/school/departments/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/departments/page.tsxMSW 兜底) |
| 16 | admin/school/academic-year/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/academic-year/page.tsxMSW 兜底) |
| 17 | admin/school/grades/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/grades/page.tsxMSW 兜底) |
| 18 | admin/school/grades/insights/page.tsx | §四补充批次 | ✅ /shell/admin/school/grades/insights/page.tsxMSW 兜底) |
| 19 | admin/announcements/page.tsx | §9.4 第 10 行 ❌ | ✅ /shell/admin/announcements/page.tsxMSW 兜底) |
| 20 | admin/announcements/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/announcements/[id]/page.tsxMSW 兜底) |
| 21 | admin/announcements/[id]/edit/page.tsx | §四补充批次 | ✅ /shell/admin/announcements/[id]/edit/page.tsxMSW 兜底) |
| 22 | admin/files/page.tsx | §9.4 第 11 行 ❌ | ✅ /shell/admin/files/page.tsxMSW 兜底) |
| 23 | admin/ai-settings/page.tsx | §9.4 第 12 行 ❌ | ✅ /shell/admin/ai-settings/page.tsxMSW 兜底) |
| 24 | admin/settings/page.tsx | §9.4 第 13 行 ❌(→ /system | ✅ /shell/admin/system/page.tsxMSW 兜底) |
| 25 | admin/course-plans/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/page.tsxMSW 兜底) |
| 26 | admin/course-plans/create/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/create/page.tsxMSW 兜底) |
| 27 | admin/course-plans/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/[id]/page.tsxMSW 兜底) |
| 28 | admin/course-plans/[id]/edit/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/[id]/edit/page.tsxMSW 兜底) |
| 29 | admin/curriculum-map/page.tsx | §四补充批次 | ✅ /shell/admin/curriculum-map/page.tsxMSW 兜底) |
| 30 | admin/elective/page.tsx | §四补充批次 | ✅ /shell/admin/elective/page.tsxMSW 兜底 |
| 31 | admin/elective/create/page.tsx | §四补充批次 | ✅ /shell/admin/elective/create/page.tsxMSW 兜底) |
| 32 | admin/elective/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/elective/[id]/page.tsxMSW 兜底) |
| 33 | admin/elective/[id]/edit/page.tsx | §四补充批次 | ✅ /shell/admin/elective/[id]/edit/page.tsxMSW 兜底) |
| 34 | admin/questions/page.tsx | §四补充批次 | ✅ /shell/admin/questions/page.tsxMSW 兜底) |
| 35 | admin/lesson-plans/page.tsx | §四补充批次 | ✅ /shell/admin/lesson-plans/page.tsxMSW 兜底) |
| 36 | admin/lesson-plans/[planId]/view/page.tsx | §四补充批次 | ✅ /shell/admin/lesson-plans/[planId]/view/page.tsxMSW 兜底) |
| 37 | admin/error-book/page.tsx | §四补充批次 | ✅ /shell/admin/error-book/page.tsxMSW 兜底) |
| 38 | admin/scheduling/auto/page.tsx | §四补充批次 | ✅ /shell/admin/scheduling/auto/page.tsxMSW 兜底) |
| 39 | admin/scheduling/changes/page.tsx | §四补充批次 | ✅ /shell/admin/scheduling/changes/page.tsxMSW 兜底) |
| 40 | admin/scheduling/rules/page.tsx | §四补充批次 | ✅ /shell/admin/scheduling/rules/page.tsxMSW 兜底) |
| 41 | admin/attendance/page.tsx | §四补充批次 | ✅ /shell/admin/attendance/page.tsxMSW 兜底) |
**ARCH §9.4 规划但 CICD 无源5 页)**
**ARCH §9.4 规划但 CICD 无源5 项,已全部新建)**
| # | ARCH 目标路由 | 说明 | 契约 |
| --- | --------------------------- | -------------------------------- | ------------------- |
| A | `/shell/admin/students` | ARCH 新建CICD 无 | ❌ |
| B | `/shell/admin/teachers` | ARCH 新建CICD 无 | ❌ |
| C | `/shell/admin/organization` | ARCH 新建CICD 无 | ❌ |
| D | `/shell/admin/viewports` | ARCH 新建,对齐 004 §5.4 | ❌ |
| E | `/shell/admin/plugins` | ARCH 新建plugin-manager 升级) | ✅ `config-service` |
| # | ARCH 目标路由 | 说明 | 契约 | portal-shell 状态2026-08-04 |
| --- | ------------------------- | -------------------------------- | ----------------- | -------------------------------------------- |
| A | /shell/admin/students | ARCH 新建CICD 无 | ❌ → MSW 兜底 | ✅ /shell/admin/students/page.tsx |
| B | /shell/admin/teachers | ARCH 新建CICD 无 | ❌ → MSW 兜底 | ✅ /shell/admin/teachers/page.tsx |
| C | /shell/admin/organization | ARCH 新建CICD 无 | ❌ → MSW 兜底 | ✅ /shell/admin/organization/page.tsx |
| D | /shell/admin/viewports | ARCH 新建,对齐 004 §5.4 | ❌ → MSW 兜底 | ✅ /shell/admin/viewports/page.tsx |
| E | /shell/admin/plugins | ARCH 新建plugin-manager 升级) | ✅ config-service | ✅ /shell/admin/plugins/page.tsx真实契约 |
---
**portal-shell 额外新增3 项 redirect 入口)**
## 七、关键结论
1. **portal-shell 管理域完成度1/24 ≈ 4%**(仅仪表盘),是各角色域中完成度最低的。
2. **缺失 23 页全部属 B5 批次**,节奏紧迫,建议立即启动契约工单。
3. **契约就绪仅 2 个**`adminDashboard`(已用)+ `config-service`plugins未实现
4. **CICD 有 21 页 ARCH §9.4 未规划**,是教师域功能在管理员视角的复用,建议作为 B5 末或 B6 补充批次,按需取用。
5. **高复杂度模块**audit-logs4 页 + 导出、school6 子页 + 跨表关联、scheduling算法、error-book多图表聚合
6. **CICD admin/layout.tsx 无独立逻辑**portal-shell 保持 passthrough 即可,侧边栏与权限由 shell/layout + 各 page.tsx 分担。
7. **建议实施顺序**:契约就绪的 `plugins` → 高价值低复杂度的 `users/roles/permissions/invitation-codes/announcements/files/ai-settings` → 高复杂度的 `audit-logs/*` + `school/*` → ARCH 新建的 `students/teachers/organization/viewports/system` → §四补充批次 16 页。
| # | portal-shell 路由 | 说明 |
| --- | ----------------------- | ------------------------------------------ |
| α | /shell/admin/classes | redirect → /shell/admin/school/classes |
| β | /shell/admin/scheduling | redirect → /shell/admin/scheduling/changes |
| γ | /shell/admin/school | redirect → /shell/admin/school/schools |

View File

@@ -1,10 +1,10 @@
# 家长域Parent待完成功能分析
# 家长域Parent实现完整性核查报告
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\parent\`
> 参考项目:`e:\Desktop\CICD\src\app\(dashboard)\parent\`
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\parent\`
> 规划依据:`apps/portal-shell/ARCHITECTURE.md` §9.324 页B4 批次)
> 分析日期2026-07-24
> 任务范围:仅分析,不写代码
> 核查日期2026-08-04
> 任务范围:仅核查与文档更新,不修改源代码
---
@@ -41,6 +41,49 @@
> **重要提示**portal-shell 已转向微服务 + BFF 架构,迁移时**不能照搬 CICD 的 Server Action + Drizzle 直查模式**。家长页面应通过 `teacher-bff` / `data-ana` 等服务的 gRPC/HTTP API 获取数据,前端通过 hooks 消费。家长域多为**只读视图**,重点在 `myChildren` 契约 + `child-overview` 聚合。
### 1.4 portal-shell 家长域资产核查features / lib/api / mocks已实际读取源码确认
#### 1.4.1 features/parent/ client 组件
**`src/features/parent/` 目录不存在**。当前 `src/features/` 下仅有:`admin` / `notifications` / `settings` / `shared` / `student` / `teacher`
> **结论**:家长域**完全没有 features 层 client 组件**。CICD 中家长域依赖的模块组件(`ParentDashboard` / `ChildCard` / `ParentChildrenDataPage` / `ParentNoChildrenPage` / `ChildDetailHeader` / `SiblingSwitcher` / `StudentGradeSummary` / `GradeTrendCard` / `ReportCardView` / `ParentAttendanceCalendar` / `CoursePlanList` / `LessonPlanReadonlyView` / `StudentDiagnosticView` / `PracticeHistory` / `ParentSelectionView` / `LeaveRequestForm` 等)在 portal-shell 中**均未建立**,需在 B4 批次逐模块重建或下沉。
#### 1.4.2 lib/api/ 家长相关 API hooks
家长相关 hooks 分布在 2 个文件,共 **5 个 hooks**
**`src/lib/api/dashboard.ts`1 个 hook**
- `useParentDashboard()`:查询 `parentDashboard` 根字段,返回 `ParentDashboard | null`。operation`GET_PARENT_DASHBOARD_DOC`,类型 `ParentDashboard` 字段 snake_case 对齐 data-ana 子图。
**`src/lib/api/parent.ts`4 个 hooks + 领域模型)**
- `useParentChildren()`:查询 `myChildren``GET_MY_CHILDREN_OVERVIEW_DOC`),返回 `ChildSummary[]`(含 `recentGrades` / `attendance` / `homeworkCompletion`
- `useLeaveRequests(childId, status)`:查询 `leaveRequests``GET_LEAVE_REQUESTS_DOC`),按孩子 ID 与状态筛选
- `useApproveLeave()``APPROVE_LEAVE_DOC` mutation失败抛 `ApiError`
- `useRejectLeave()``REJECT_LEAVE_DOC` mutation需 reason失败抛 `ApiError`
领域模型类型:`ChildSummary` / `ChildGrade` / `ChildAttendance` / `ChildHomeworkCompletion` / `LeaveRequest` / `LeaveType` / `LeaveStatus`
operations 文件:`src/lib/api/operations/parent.graphql.ts`4 个 document。导出`src/lib/api/index.ts` 第 36 行 `export * from "./parent";`
> **结论**lib/api 层家长 hooks 已就绪 5 个(仪表盘 + 子女概览 + 请假审批链路。§9.3 其余 18+ 页面所需 hooksgrades / report-card / exams / homework / attendance / classes / course-plans / lesson-plans / error-book / diagnostic / learning-path / practice / elective / preferences 等)**均未建立**。注:`lib/api/` 下虽有 `grades.ts` / `attendance.ts` 等同名文件,但属学生域 / 通用域 hooks家长域多子女聚合 + 只读过滤尚未单独建模。
#### 1.4.3 src/mocks/graphql-data.ts 家长相关 mock 数据
| Mock 资产 | 位置 | 状态 | 服务对象 |
| -------------------------------- | ---------------------- | ----------------- | ----------------------------------------------------------------------------------------- |
| `mockParentDashboard` | `graphql-data.ts:84` | ✅ 存在 | `GetParentDashboard` 查询(仪表盘页) |
| `GetParentDashboard` case 分支 | `graphql-data.ts:5577` | ✅ 存在 | 仪表盘 hook 兜底 |
| `mockLeaveRequests` | `graphql-data.ts:4143` | 🟡 存在但属教师域 | `GetTeacherLeaveRequests`(教师域 B2 |
| `GetMyChildrenOverview` mock | — | ❌ **缺失** | `useParentChildren` 无兜底 |
| `GetLeaveRequests`家长版mock | — | ❌ **缺失** | `useLeaveRequests` 无兜底(教师版结构 `{ items, total }` 与家长版 `LeaveRequest[]` 不同) |
| `ApproveLeave` mock | — | ❌ **缺失** | `useApproveLeave` 无兜底 |
| `RejectLeave` mock | — | ❌ **缺失** | `useRejectLeave` 无兜底 |
> **结论**:家长域 mock **仅仪表盘 1 项就绪**`useParentChildren` / `useLeaveRequests`(家长版)/ `useApproveLeave` / `useRejectLeave` 4 个 hook 在 dev 环境**无 MSW 兜底**(会触发 Apollo 错误或空态)。需在 B4 补齐 4 个 mock否则 dev 环境无法演示。
---
## 二、缺失页面清单(按模块分组)
@@ -51,7 +94,7 @@
- **portal-shell 实现**:基础仪表盘,显示孩子平均分 / 班级排名 / 薄弱知识点 / 预警通知
- **技术栈**Client Component + `useParentDashboard()` Hook + `DashboardShell` / `StatCard` 共享组件
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\dashboard\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\dashboard\page.tsx`
- **CICD 技术栈**Server Component + `getParentDashboardAction()` Server Action + `ParentDashboard` 视图组件 + React `use()` 流式渲染 + `generateMetadata`
- **CICD 关键功能**
- 无子女时显示 `ParentNoChildrenPage` 空态
@@ -89,7 +132,7 @@
#### 2.2.1 ❌ `/shell/parent/children/[studentId]`(子女详情页,缺失)⚠️ 重要
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\children\[studentId]\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\children\[studentId]\page.tsx`
- **功能描述**
- **单个子女的完整详情视图**(家长域最核心页面)
- **双重权限校验**
@@ -126,7 +169,7 @@
#### 2.3.1 ❌ `/shell/parent/grades`(子女成绩列表,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\grades\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\grades\page.tsx`
- **功能描述**
- **多子女成绩对比视图**(家长域特色)
- 每个子女一块:姓名标题 + **导出按钮**`ParentExportButton`,按 studentId 导出)
@@ -146,7 +189,7 @@
#### 2.3.2 ❌ `/shell/parent/grades/report-card`(子女成绩报告卡,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\grades\report-card\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\grades\report-card\page.tsx`
- **功能描述**
- 学年 / 学期可切换的成绩报告卡(家长视角)
- **必填查询参数**`studentId`(必须在家长子女范围内)
@@ -215,7 +258,7 @@
#### 2.6.1 ❌ `/shell/parent/attendance`(子女考勤,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\attendance\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\attendance\page.tsx`
- **功能描述**
- **多子女考勤对比视图**(家长域特色)
- 顶部 `headerExtra`:出勤率卡片(`ParentAttendanceRateCard`+ 考勤预警(`ParentAttendanceWarning`
@@ -253,7 +296,7 @@
#### 2.8.1 ❌ `/shell/parent/course-plans`(课程计划列表,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\course-plans\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\course-plans\page.tsx`
- **功能描述**
- 家长视角:解析**所有孩子的班级 ID**,用于过滤课程计划
- 并行查询每个子女的活跃班级 ID`getStudentActiveClassId`
@@ -271,7 +314,7 @@
#### 2.8.2 ❌ `/shell/parent/course-plans/[id]`(课程计划详情,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\course-plans\[id]\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\course-plans\[id]\page.tsx`
- **功能描述**
- 家长视角:仅允许查看**孩子所在班级**的课程计划
- 解析所有孩子的班级 ID传入权限上下文
@@ -292,7 +335,7 @@
#### 2.9.1 ❌ `/shell/parent/lesson-plans`(教案列表,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\lesson-plans\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\lesson-plans\page.tsx`
- **功能描述**
- 家长视角:仅查看**已发布**教案(`status: "published"`
- 学科选项并行加载(`getSubjectOptions`
@@ -310,7 +353,7 @@
#### 2.9.2 ❌ `/shell/parent/lesson-plans/[planId]/view`(教案只读详情,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\lesson-plans\[planId]\view\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\lesson-plans\[planId]\view\page.tsx`
- **功能描述**
- 家长视角:仅可查看**孩子所在年级**的已发布课案V4 P0-1 修复,防跨年级信息泄露)
- **scope 校验**`assertPlanInScope(plan, ctx)`,失败 `notFound()`
@@ -334,7 +377,7 @@
#### 2.10.1 ❌ `/shell/parent/error-book`(子女错题本,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\error-book\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\error-book\page.tsx`
- **功能描述**
- **单/多子女分支渲染**(家长域特色):
- **单子女**:直接展示 `StatsGrid` 5 列统计卡片(总数 / 新增 / 学习中 / 已掌握 / 待复习)
@@ -358,7 +401,7 @@
#### 2.11.1 ❌ `/shell/parent/diagnostic`(子女学情诊断,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\diagnostic\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\diagnostic\page.tsx`
- **功能描述**
- **多子女诊断对比视图**v4-P1-9 容错增强)
- 预先查询所有子女姓名(`getUserNamesByIds`),用于错误卡片展示
@@ -394,7 +437,7 @@
#### 2.13.1 ❌ `/shell/parent/practice`(子女练习查看,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\practice\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\practice\page.tsx`
- **功能描述**
- **单/多子女分支渲染**(家长域特色):
- **单子女**:直接展示 4 列 `StatsGrid`(总练习次数 / 已完成 / 总答题数 / 正确率)+ `PracticeHistory` 历史列表
@@ -419,7 +462,7 @@
#### 2.14.1 ❌ `/shell/parent/elective`(子女选课查看,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\elective\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\elective\page.tsx`
- **功能描述**
- **多子女选课记录查看**(只读,家长不能选课)
- 每个子女一块:`ParentSelectionView`(子女姓名 + 选课记录列表)
@@ -439,7 +482,7 @@
#### 2.15.1 ❌ `/shell/parent/leave`(家长在线请假,缺失)
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\parent\leave\page.tsx`
- **CICD 参考实现**`e:\Desktop\CICD\src\app\(dashboard)\parent\leave\page.tsx`
- **功能描述**
- **家长域少有的"写"操作页面**(其他多为只读)
- 顶部:**在线请假表单**`LeaveRequestForm`,下拉选择子女,自动写入 classId

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
# 教师域Teacher待完成功能分析
# 教师域Teacher待完成功能分析
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\teacher\`53 个 page.tsx
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\teacher\`53 个 page.tsx
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.1(教师域 56 页B1+B2
> 分析日期2026-07-24
> 分析方式:逐页面对比 CICD 实现 vs portal-shell 实现,逐个读取 page.tsx + features/*-client.tsx
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\teacher\`CICD 原版,53 个 page.tsx
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\teacher\`63 个 page.tsx
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.1(教师域 56 页规划B1+B2+B2末
> 分析日期2026-08-04
> 分析方式:逐页面读取 page.tsx 文件内容 + features/*-client.tsx 集成核查 + lib/api hooks + mocks/graphql-data.ts 覆盖核查
---
@@ -13,567 +13,467 @@
1. CICD 单体Next.js App Router + Server Actions + Drizzle作为**功能基线**,反映"老版单体已实现"的教师功能完整态。
2. portal-shell 作为**目标态**,遵循 ARCH §9.1 的契约与批次规划B1+B2+B2末
3. portal-shell 普遍采用「Server Component page.tsx 仅做 `<Suspense>` 边界包裹 + Client Component `*-client.tsx` 承载业务」的统一架构CICD 多为「Server Component 直接拉数据 + Rich Client Component 渲染」。
4. ARCH §9.1 规划 56 页portal-shell 已实现 53 页,**3 页缺口** = `exams/[id]/proctoring`B2末+ `notifications`B1 共享)+ `settings`B1 共享)。
4. ARCH §9.1 规划 56 页portal-shell 已实现 **63 页**(含 5 个路径别名 `/classes/my``/classes/my/[id]``/exams/all``/exams/create``/homework/assignments/create` 复用同一 client+ 3 个占位页 `/classes/students``/exams/grading``/exams/grading/[submissionId]`+ 2 个 ARCH §9.1 原未规划但已补做 `/exams/[id]/proctoring``/diagnostic/student/[studentId]`)。
5. CICD 中 `exams/page.tsx``classes/page.tsx``exams/grading/*``exams/create` 等为**重定向/等价路径**,不算独立功能缺口。
6. CICD 中 `diagnostic/student/[studentId]` 在 ARCH §9.1 中**未规划**ARCH 仅规划 `diagnostic` + `diagnostic/class/[classId]` 共 2 页),属 CICD 历史功能portal-shell 缺失但**不违反 ARCH**。
6. CICD 中 `diagnostic/student/[studentId]` 在 ARCH §9.1 中**未规划**ARCH 仅规划 `diagnostic` + `diagnostic/class/[classId]` 共 2 页),属 CICD 历史功能portal-shell 已补做(**待同步更新 ARCH §9.1 + 004 + arch.db**
### 0.1 三态分类严格定义(本次核查采用)
| 状态 | 定义 | 判据 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| ✅ 完整实现 | page.tsx 引入并渲染 `features/teacher/*-client.tsx` 客户端组件,或 page.tsx 自身包含真实业务逻辑(调用 lib/api hooks、渲染数据、处理 loading/error/empty 三态) | 文件内 `import``*-client.tsx``<XxxClient />` 渲染;或直接调用 `useXxx` hook |
| 🟡 占位空态 | page.tsx 仅用 `ListPageShell` / `DetailPageShell` / `FormPageShell` / `WorkbenchPageShell` 等 page-templates 直接渲染空态(如 `empty` 属性或无 children 内容),**没有引入任何 `*-client.tsx`,也没有真实业务逻辑** | 文件内仅 `import { ListPageShell } from "@/shared/components/page-templates"`,无 client 组件 |
| ❌ 缺失 | 文件不存在 | 路径下无 page.tsx |
> **重要说明**:本核查的"完整实现"指**页面骨架与数据流闭环**client 组件已集成、hooks 已接入、三态已处理),**不代表与 CICD 功能完全对等**。与 CICD 的功能差距详见 §三.B原 §3.4-§3.18 内容保留,归类为"功能差距"而非"占位空态")。
### 0.2 配套资产核查
| 资产 | 路径 | 覆盖情况 |
| ----------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| features/teacher/*-client.tsx | `src/features/teacher/<module>/` | **55 个页面级 client 组件**(不含 dashboard-cards-client 子组件),覆盖 20 个模块,详见 §七.2 |
| lib/api hooks | `src/lib/api/<module>.ts` | **20 个模块文件**ai/analytics/attendance/classes/course-plans/dashboard/diagnostic/elective/error-book/exams/grades/homework/knowledge-graph/leave/lesson-plans/practice/proctoring/questions/schedule-changes/students/textbooks另含 admin/universal/sidebar/topbar/notifications/settings/profile 等共享域文件) |
| mocks/graphql-data.ts | `src/mocks/graphql-data.ts` | **269KB / 116 个 mock 数据集**(含教师域全部模块 + 学生/家长/管理域),所有占位页与功能差距页均有 MSW 兜底 |
---
## 一、页面完成度总览
| 状态 | 数量 | 说明 |
| --------------------- | ---- | ---------------------------------------------------------------------------------------------------- |
| ✅ 已完成且功能完整 | 33 | 功能与 CICD 持平或为 ARCH 新建且实现完整(含 MSW 兜底契约页) |
| 🟡 已完成但功能不完整 | 14 | 页面存在但子功能/技术栈与 CICD 有差距 |
| ❌ 完全缺失 | 4 | `exams/[id]/proctoring``diagnostic/student/[studentId]``/shell/notifications``/shell/settings` |
| 状态 | 数量 | 说明 |
| ----------- | ------ | ---------------------------------------------------------------------------- |
| ✅ 完整实现 | **60** | page.tsx 集成 `*-client.tsx` 或自身含真实业务逻辑lib/api hook + 三态处理) |
| 🟡 占位空态 | **3** | 仅用 page-templates 渲染空态,无 client 组件集成,无真实业务 |
| ❌ 完全缺失 | **0** | 教师域 63 个 page.tsx 全部存在 |
### ✅ 已完成且功能完整33 页)
### 与原核查2026-07-24的差异
| 模块 | 页面 | 备注 |
| ---------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| dashboard | `/shell/teacher`teacher/page.tsx | 仪表盘4 StatCard + 班级概况 + 预警(注:标题用硬编码中文,违反 i18n但功能完整 |
| exams | `/shell/teacher/exams/[id]/analytics` | 汇总卡片 + 分数段分布图 + 每题正确率 + 学生排名,混合契约 ✅ `assignmentAnalysis` |
| homework | `/shell/teacher/homework/submissions/[submissionId]` | 单份提交批改,含题目分数/教师评语/AI 建议应用/上下导航/总反馈 |
| homework | `/shell/teacher/homework/submissions/[submissionId]/scan-grading` | 扫描批改 |
| homework | `/shell/teacher/homework/submissions` | 提交列表 |
| homework | `/shell/teacher/homework/assignments/[id]/submissions` | 作业子提交列表 |
| homework | `/shell/teacher/homework` | 作业列表 |
| grades | `/shell/teacher/grades` | 成绩列表 |
| grades | `/shell/teacher/grades/analytics` | 成绩分析,汇总 + 分布 + 排名,混合契约 |
| grades | `/shell/teacher/grades/stats` | 成绩统计 |
| grades | `/shell/teacher/grades/report-card` | 报告卡 |
| attendance | `/shell/teacher/attendance` | 考勤列表 |
| attendance | `/shell/teacher/attendance/report` | 考勤报告 |
| attendance | `/shell/teacher/attendance/stats` | 考勤统计 |
| classes | `/shell/teacher/classes` | 班级列表(含 gradeId/subjectId/q 筛选) |
| classes | `/shell/teacher/classes/schedule` | 班级课表 |
| students | (见 🟡 学生列表,因缺科目成绩) | — |
| course-plans | `/shell/teacher/course-plans``/[id]` | 2 页 |
| elective | `/shell/teacher/elective``/create``/[id]/edit` | 3 页 |
| error-book | `/shell/teacher/error-book` | ✅ `errorBookItems/Stats` 真实契约 |
| diagnostic | `/shell/teacher/diagnostic` | 诊断列表 |
| analytics | `/shell/teacher/analytics``/[studentId]` | ARCH 新建 2 页MSW 兜底 |
| ai | `/shell/teacher/ai/ai-assist``/ai-lesson-plan``/ai-report` | ARCH 新建 B2末 3 页,全 MSW 兜底,工作台布局完整 |
| knowledge-graph | `/shell/teacher/knowledge-graph` | ARCH 新建 B2末三栏工作台`knowledgePoint(id)` ✅ 真实 + 列表 MSW |
| questions | `/shell/teacher/questions` | 题库列表,🟡 `question(id)` |
| textbooks | `/shell/teacher/textbooks``/[id]` | 2 页,🟡 `textbook(id)` |
| lesson-plans | `/shell/teacher/lesson-plans``/new``/calendar``/heatmap``/library` | 5 页edit 见 🟡) |
| practice | `/shell/teacher/practice` | 练习 |
| leave | `/shell/teacher/leave` | 请假 |
| schedule-changes | `/shell/teacher/schedule-changes` | 调课 |
| 项目 | 原07-24 | 现08-04 | 变化 |
| -------- | ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
| 总页面数 | 53 | 63 | +105 别名 + 3 占位 + 2 补做) |
| ✅ 完整 | 33 | 60 | +27原 14 页"功能不完整"实为已集成 client 归入 ✅ + 新补做 2 页 + 新增别名 5 页 - 占位 3 页归 🟡 - 共享域 2 页非教师域) |
| 🟡 占位 | 14 | 3 | -11原 14 页均集成 client 组件归 ✅;新增 3 个真占位页) |
| ❌ 缺失 | 4 | 0 | -4proctoring 与 diagnostic/student 已补做notifications/settings 属共享域非教师域核查范围) |
### 🟡 已完成但功能不完整14 页)
### ✅ 完整实现60 页)
详见 §三。
按模块分布(含别名/复用):
### ❌ 完全缺失4 页)
| 模块 | 页面数 | 页面路径 |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| dashboard | 1 | `/shell/teacher`page.tsx 含 useTeacherDashboard hook + 7 张卡片 + i18n + 三态) |
| ai | 3 | `/ai/ai-assist``/ai/ai-lesson-plan``/ai/ai-report`(全 MSWB2末工作台 |
| analytics | 2 | `/analytics``/analytics/[studentId]`MSW 兜底) |
| attendance | 4 | `/attendance``/report``/sheet``/stats` |
| classes | 5 | `/classes``/classes/my`(别名)、`/classes/[id]``/classes/my/[id]`(别名)、`/classes/schedule` |
| course-plans | 2 | `/course-plans``/course-plans/[id]` |
| diagnostic | 3 | `/diagnostic``/diagnostic/class/[classId]``/diagnostic/student/[studentId]`(已补做) |
| elective | 3 | `/elective``/elective/create``/elective/[id]/edit` |
| error-book | 1 | `/error-book`(✅ errorBookItems/Stats 真实契约) |
| exams | 9 | `/exams``/exams/all`(别名)、`/exams/new``/exams/create`(别名)、`/exams/[id]``/exams/[id]/analytics``/exams/[id]/build``/exams/[id]/edit``/exams/[id]/proctoring`(已补做) |
| grades | 5 | `/grades``/grades/analytics``/grades/entry``/grades/report-card``/grades/stats` |
| homework | 8 | `/homework``/homework/new``/homework/assignments/create`(别名)、`/homework/[id]``/homework/assignments/[id]/submissions``/homework/submissions``/homework/submissions/[submissionId]``/homework/submissions/[submissionId]/scan-grading` |
| knowledge-graph | 1 | `/knowledge-graph`B2末混合契约 knowledgePoint ✅) |
| leave | 1 | `/leave` |
| lesson-plans | 6 | `/lesson-plans``/lesson-plans/new``/lesson-plans/calendar``/lesson-plans/heatmap``/lesson-plans/library``/lesson-plans/[planId]/edit` |
| practice | 1 | `/practice` |
| questions | 1 | `/questions` |
| schedule-changes | 1 | `/schedule-changes` |
| students | 1 | `/students`(原 /classes/students 已迁到顶级) |
| textbooks | 2 | `/textbooks``/textbooks/[id]` |
详见 §二
> **注**:原"🟡 已完成但功能不完整"的 14 页dashboard、exams/page、exams/[id]、exams/new、exams/[id]/edit、exams/[id]/build、classes/[id]、classes/page、students、homework/[id]、homework/new、lesson-plans/[planId]/edit、grades/entry、attendance/sheet、diagnostic/class/[classId])均**已集成真实 client 组件**,按本次严格三态定义归入 ✅;其与 CICD 的功能差距见 §三.B§3.4-§3.18 保留原核查的详细对比分析)
### 🟡 占位空态3 页)
详见 §三.A§3.1-§3.3)。
### ❌ 缺失0 页)
教师域 63 个 page.tsx 全部存在,无缺失。
> **共享域说明**:原 `/shell/notifications` 与 `/shell/settings` 为 B1 共享页(非教师域独有),不属于本次教师域核查范围。如需补做,应在 `/shell/notifications/page.tsx` 与 `/shell/settings/page.tsx` 实现,供所有角色共用。
---
## 二、缺失页面清单
### 2.1 `/shell/teacher/exams/[id]/proctoring`(监考工作台)
### 2.1 教师域缺失页面
**无缺失**。教师域 63 个 page.tsx 全部存在。
### 2.2 已补做的原"缺失"页面(相对 2026-07-24 核查)
#### 2.2.1 `/shell/teacher/exams/[id]/proctoring`(监考工作台)— ✅ 已补做
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\teacher\exams\[id]\proctoring\page.tsx`
- **功能描述**:实时监考面板,包含 `ProctoringDashboard` 组件,并行拉取:
- `getExamForProctoring(id)` — 考试基础信息
- `getExamProctoringSummary(id)` — 监考汇总(应到/实到/异常数
- `getStudentProctoringStatuses(id)` — 学生实时状态列表
- `getRecentProctoringEvents(id, 20)` — 最近 20 条监考事件
- **技术栈**Server Component + `requirePermission(Permissions.EXAM_PROCTOR)` + `ProctoringDashboard` 客户端组件(实时 WS 推送)
- **ARCHITECTURE.md 契约**schema 无,需 WS 契约工单 + MSW 先行)
- **批次**B2 末二期WS
- **建议**:✅ 必须补做。需先定 WS 协议契约push-gateway再写 MSW mock最后实现 `ProctoringDashboard` 客户端组件。这是 B2末 明确规划页。
- **portal-shell 现状**`exams/[id]/proctoring/page.tsx` + `features/teacher/exams/proctoring-client.tsx`
- **实现状态**:✅ page.tsx 引入 `ProctoringClient``<Suspense fallback={<WorkbenchPageSkeleton />}>` 包裹client 组件使用 `useExamProctoring` / `useStudentProctoringStatuses` / `useRecentProctoringEvents` 三个 Apollo hook全 MSW 兜底);通过 5s 轮询模拟实时刷新,后端补齐 WS subscription 后切换。
- **ARCHITECTURE.md 契约**schema 无,需 WS 契约工单 + MSW 先行,已 MSW 兜底
- **批次**B2 末二期WS— MSW 阶段已完成,待 WS 契约就绪后切换。
### 2.2 `/shell/teacher/diagnostic/student/[studentId]`(学生诊断详情)
#### 2.2.2 `/shell/teacher/diagnostic/student/[studentId]`(学生诊断详情)— ✅ 已补做
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\teacher\diagnostic\student\[studentId]\page.tsx`
- **功能描述**:单个学生的学情诊断页,包含:
- `StudentDiagnosticView` 组件,渲染 `MasteryRadarPoint` 雷达图(学生 vs 班级平均对比)
- `getStudentMasterySummary(studentId)` — 学生掌握度汇总
- `getDiagnosticReports({ studentId }, ctx.dataScope)` — 诊断报告列表(教师可看草稿)
- `getKnowledgePointStats(studentClassId)` — 班级平均掌握度(雷达图对比)
- DataScope 二次校验:`class_taught` scope 验师生关系,`class_members`/`children` scope 限制访问
- **技术栈**Server Component + `requirePermission(Permissions.DIAGNOSTIC_READ)` + `WidgetBoundary` + `StudentDiagnosticView` 客户端组件
- **ARCHITECTURE.md 契约**:🟡(`diagnosticReports` ✅,但单生维度查询未明确)— **注意ARCH §9.1 仅规划 2 页(`/diagnostic` + `/diagnostic/class/[classId]`),未含学生维度**
- **批次**B2CICD 历史功能ARCH 未规划)
- **建议**:⚠️ 建议补做。虽 ARCH §9.1 未明确规划,但 CICD 已有完整实现且属于教师核心诊断场景(查看单个学生的掌握度雷达图 + 班级对比)。补做时需同步更新 ARCH §9.1 + 004。
- **portal-shell 现状**`diagnostic/student/[studentId]/page.tsx` + `features/teacher/diagnostic/student-diagnostic-client.tsx`
- **实现状态**:✅ page.tsx 引入 `StudentDiagnosticClient``<Suspense fallback={<DetailPageSkeleton />}>` 包裹client 组件使用 `useStudentDiagnostic` Apollo hookMSW 兜底)。
- **ARCHITECTURE.md 契约**:🟡 `diagnosticReports` ✅ schema 真实字段,单生维度查询未明确。
- **批次**B2CICD 历史功能ARCH §9.1 原未规划)— **需同步更新 ARCH §9.1 + 004 + arch.db**
### 2.3 `/shell/notifications`通知中心B1 共享
### 2.3 共享域缺口(非教师域核查范围,仅作记录
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\teacher\notifications\page.tsx`(教师角色入口)
- **功能描述**:通知中心,按 `userId` 拉取通知列表
- **技术栈**Server Component + `notifications(userId)` 查询
- **ARCHITECTURE.md 契约**:✅ `notifications(userId)` schema 已就绪
- **批次**B1共享所有角色共用
- **建议**:✅ 必须补做。B1 共享页schema 已就绪,缺页面入口。需在 `/shell/notifications/page.tsx` 实现,供教师/学生/家长/管理员共用。
| 路径 | 状态 | 说明 |
| ---------------------- | ------- | ------------------------------------------------------------------- |
| `/shell/notifications` | ❌ 缺失 | B1 共享,`notifications(userId)` ✅ schema 已就绪,需在共享域补页面 |
| `/shell/settings` | ❌ 缺失 | B1 共享,需在共享域补页面 |
### 2.4 `/shell/settings`设置B1 共享
### 2.4 不算缺失的"伪缺失"(路径合并/重定向/别名
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\teacher\settings\page.tsx`
- **功能描述**:用户设置页(个人资料、偏好、安全等)
- **技术栈**Server Component
- **ARCHITECTURE.md 契约**:✅
- **批次**B1共享
- **建议**:✅ 必须补做。B1 共享页,需在 `/shell/settings/page.tsx` 实现。
以下路径在 portal-shell 中通过路径合并、别名或等价入口覆盖,**不算缺失**
### 2.5 不算缺失的"伪缺失"(路径合并/重定向)
以下 CICD 路径在 portal-shell 中通过路径合并或等价入口覆盖,**不算缺失**
| CICD 路径 | portal-shell 等价 | 说明 |
| --------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `exams/page.tsx` | `/exams/page.tsx`(直接列表) | CICD 是 redirect 到 `/exams/all`portal-shell 直接渲染列表 |
| `exams/all/page.tsx` | `/exams/page.tsx`ExamsListClient | 同上,功能等价(但筛选/计数/分页有差距,见 §3.1 |
| `exams/create/page.tsx` | `/exams/new/page.tsx`NewExamClient | CICD 有 `/create`ExamForm 简单表单)+ `/new`ExamRichForm 富文本portal-shell 合并为 `/new`(简单表单),富文本编辑只在 `/[id]/edit`(见 §3.4 |
| `exams/grading/page.tsx` | 无CICD 自身是 redirect | CICD `grading/page.tsx``redirect("/teacher/homework/submissions")`,无实际功能 |
| `exams/grading/[submissionId]/page.tsx` | 无CICD 自身是 redirect | 同上,仅 redirect |
| `classes/page.tsx` | `/classes/page.tsx`ClassesListClient | CICD 是 redirect 到 `/classes/my`portal-shell 直接渲染列表 |
| `classes/my/page.tsx` | `/classes/page.tsx`ClassesListClient | 路径合并MyClassesGrid 改为 ClassesTable视觉差异功能等价 |
| `classes/my/[id]/page.tsx` | `/classes/[id]/page.tsx`ClassDetailClient | 路径合并,但功能差距大(见 §3.5 |
| `classes/students/page.tsx` | `/students/page.tsx`StudentsListClient | 路径从 `/classes/students` 迁到顶级 `/students`ARCH §9.1 规划),功能差距见 §3.6 |
| CICD 路径 | portal-shell 等价 | 说明 |
| --------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `exams/page.tsx` | `/exams/page.tsx`ExamsListClient | CICD 是 redirect 到 `/exams/all`portal-shell 直接渲染列表 + 同时保留 `/exams/all` 别名 |
| `exams/all/page.tsx` | `/exams/all/page.tsx`ExamsListClient 别名) | portal-shell 已补 `/exams/all` 别名复用 `ExamsListClient` |
| `exams/create/page.tsx` | `/exams/create/page.tsx`NewExamClient 别名) | portal-shell 已补 `/exams/create` 别名复用 `NewExamClient` |
| `classes/page.tsx` | `/classes/page.tsx`ClassesListClient | CICD 是 redirect 到 `/classes/my`portal-shell 直接渲染列表 + 同时保留 `/classes/my` 别名 |
| `classes/my/page.tsx` | `/classes/my/page.tsx`ClassesListClient 别名) | portal-shell 已补 `/classes/my` 别名复用 `ClassesListClient` |
| `classes/my/[id]/page.tsx` | `/classes/my/[id]/page.tsx`ClassDetailClient 别名) | portal-shell 已补 `/classes/my/[id]` 别名复用 `ClassDetailClient` |
| `classes/students/page.tsx` | `/students/page.tsx`StudentsListClient + `/classes/students/page.tsx`(🟡 占位) | 路径从 `/classes/students` 迁到顶级 `/students`ARCH §9.1 规划);旧路径保留为占位页(见 §3.1 |
| `homework/assignments/create/page.tsx` | `/homework/assignments/create/page.tsx`NewHomeworkClient 别名) | portal-shell 已补 `/homework/assignments/create` 别名复用 `NewHomeworkClient` |
| `exams/grading/page.tsx` | CICD 自身是 redirect | CICD `grading/page.tsx``redirect("/teacher/homework/submissions")`portal-shell 保留为占位页(见 §3.2 |
| `exams/grading/[submissionId]/page.tsx` | 无CICD 自身是 redirect | 同上,仅 redirectportal-shell 保留为占位页(见 §3.3 |
---
## 三、功能不完整的页面
## 三、占位空态页面 + 与 CICD 功能差距
### 3.1 exams - 考试列表页 `/shell/teacher/exams`
> 本节分两部分:
>
> - **§三.A§3.1-§3.3**3 个**真占位页**(仅 page-templates 渲染空态,无 client 组件集成)— **本次核查最优先补做项**
> - **§三.B§3.4-§3.18**15 个**已集成 client 但与 CICD 有功能差距**的页面 — 保留原 §3.1-§3.15 详细对比分析
- **路径**`/shell/teacher/exams`
- **portal-shell 实现**`exams/page.tsx` + `features/teacher/exams/exams-list-client.tsx`
- **CICD 实现**`exams/all/page.tsx` + `exam-filters.tsx` + `ExamDataTable` + `ExamFilters`
- **已有功能**
- 关键词搜索q、状态筛选status、客户端二次筛选
- 简单表格展示title/status/examDate/duration/totalScore/actions
- 状态徽章、新建按钮、EmptyState、ListPageShell 骨架
- ✅ 真实查询 `useExams(classId)`classId 默认 `cls-001`MSW 兜底)
- **缺失功能**
-**难度筛选**difficulty filter— CICD 有 difficulty 维度筛选
-**状态计数徽章**draft/published/archived 三个 Badge + 数字)— CICD 顶部统计栏
-**真正的分页控件** — portal-shell 仅显示 `total: N`无分页器CICD `ExamDataTable` 内置分页
-**高级 DataTable 组件** — CICD 用 `ExamDataTable`(含排序/列控制/分页portal-shell 是手写 `<table>`
-**EmptyState "清除筛选" action** — CICD 在有筛选时显示"清除筛选"按钮回到 `/exams/all`portal-shell 无
-**服务端筛选** — CICD 在 Server Component 内调 `getExams({ q, status, difficulty, scope })`portal-shell 是客户端二次过滤
-**DataScope 过滤** — CICD 用 `ctx.dataScope` 做权限过滤portal-shell 无
- **技术差距**
- CICDServer Component + Drizzle `getExams` + `ExamDataTable` + `ExamFilters` 组件
- portal-shell 需补:分页器组件、状态计数 Badge 行、difficulty 筛选 select、EmptyState clearFilters action、后续契约就绪后改服务端筛选
### 三.A 占位空态页面3 页P0 优先补做)
### 3.2 exams - 考试详情页 `/shell/teacher/exams/[id]`
#### 3.1 `/shell/teacher/classes/students`(班级学生列表占位)
- **路径**`/shell/teacher/exams/[id]`
- **portal-shell 实现**`exams/[id]/page.tsx` + `features/teacher/exams/exam-detail-client.tsx`
- **CICD 实现****CICD 无此页面**CICD `/exams/[id]/` 目录下只有 analytics/build/edit-rich/proctoring 子路由,无 `page.tsx`
- **已有功能**
- ✅ 真实查询 `useExam(examId)`schema 已就绪)
- 基本信息区title/description/examDate/duration/totalScore/classId/subjectId
- 状态信息区currentStatus/statusChangedAt/statusChangedBy/createdBy/createdAt/updatedAt
- 编辑按钮status 可编辑时显示,但 onClick 无路由跳转,**按钮无实际行为**
- **缺失功能**
- ⚠️ **编辑按钮未接路由**`<Button variant="outline">{t("detail.edit")}</Button>``asChild` / `href` / `onClick`,点击无反应,应跳 `/shell/teacher/exams/[id]/edit`
- 💡 **无提交列表预览** — CICD 在 homework 详情有提交列表exam 详情可考虑加 submissions 摘要CICD 无此页,属增量)
- 💡 **无分析入口卡片** — 可加"查看分析"快捷入口跳 `/analytics`
- **技术差距**
- 编辑按钮需补 `asChild` + `<Link href=".../edit">`
- ARCH §9.1 规划此页为 M 详情页portal-shell 已满足基本契约,但交互不完整
- **路径**`/shell/teacher/classes/students`
- **page.tsx 实现**
### 3.3 exams - 新建考试表单 `/shell/teacher/exams/new`
```tsx
import { ListPageShell } from "@/shared/components/page-templates";
- **路径**`/shell/teacher/exams/new`
- **portal-shell 实现**`exams/new/page.tsx` + `features/teacher/exams/new-exam-client.tsx`
- **CICD 实现**`exams/new/page.tsx`ExamRichForm 富文本)+ `exams/create/page.tsx`ExamForm 简单)
- **已有功能**
- 简单表单classId/subjectId/title/description/examDate/duration/totalScore
- 内联校验classId/title/examDate 必填)
- ✅ mutation `useCreateExam` MSW 兜底
- 成功后 `router.push` 回列表
- **缺失功能**
-**富文本编辑器** — CICD `/exams/new``ExamRichForm`(含 Tiptap 富文本 + 题目结构portal-shell 是纯字段表单
-**班级/科目下拉选择器** — portal-shell 用 `<input type="text">`CICD 应为 selectCICD ExamRichForm 内部)
- 💡 **题目结构编辑** — CICD 富文本编辑器支持插入题目占位、分值、排序
- **技术差距**
- CICD`ExamRichForm` 客户端组件Tiptap + 结构化节点)
- portal-shell 把 CICD 的 `/exams/new`rich+ `/exams/create`basic合并为 `/exams/new`basic富文本能力下沉到 `/[id]/edit`
- **是否补做**:取决于产品决策。若新建时即需富文本,需补 Tiptap若新建只创建元数据、富文本在编辑页做则当前实现可接受
export default function ClassStudentsPage(): React.ReactElement {
return (
<ListPageShell title="班级学生" description="查看各班级学生名单" empty />
);
}
```
### 3.4 exams - 富文本试卷编辑 `/shell/teacher/exams/[id]/edit`
- **当前状态**:🟡 占位空态,仅 `ListPageShell` + `empty` 属性,无 children 内容,无 client 组件集成。
- **已有但未集成的组件**:❌ 无。`features/teacher/classes/` 仅有 `class-detail-client.tsx`、`class-schedule-client.tsx`、`classes-list-client.tsx`**无 `class-students-client.tsx`**。
- **可用资源**
- lib/api`lib/api/classes.ts` 有 `useClassStudents(classId)` hook
- mocks`graphql-data.ts` 有 `ClassStudents` mock 数据集
- 同域可复用:`/students/page.tsx` 已实现 `StudentsListClient`顶级路由ARCH §9.1 已迁出)
- **需要补全的内容**
1. **方案 A推荐**:删除 `/classes/students/page.tsx`,统一使用顶级 `/students`ARCH §9.1 已规划迁移)
2. **方案 B兼容旧路径**:改为 `redirect("/shell/teacher/students")` 重定向到顶级路由
3. **方案 C保留独立功能**:新建 `features/teacher/classes/class-students-client.tsx`,按 `classId` 查询展示某班学生名单(不同于顶级 `/students` 的全校学生视图)
- **建议**:方案 B重定向。ARCH §9.1 已明确迁移到顶级 `/students`,保留旧路径作为重定向入口即可,无需重复实现。
- **路径**`/shell/teacher/exams/[id]/edit`(对应 CICD `/exams/[id]/edit-rich`
- **portal-shell 实现**`exams/[id]/edit/page.tsx` + `features/teacher/exams/exam-edit-client.tsx`
- **CICD 实现**`exams/[id]/edit-rich/page.tsx` + `ExamRichForm` + `examNodesToEditorDoc` + `structureToEditorDoc`
- **已有功能**
- 三栏工作台:中栏富文本编辑器 + 右栏属性面板examId/totalScore/questionCount/updatedAt
- 工具栏B/I/U/H1/H2/H3/列表/P
- `useExamRichEditor` + `useSaveExamRichContent` MSW 兜底
- 保存为 HTML`editorRef.current.innerHTML`
- **缺失功能**
-**真正的 Tiptap 编辑器** — portal-shell 用 `contentEditable` + `document.execCommand`**deprecated**CICD 用 Tiptap JSON 结构化文档
-**题目结构加载** — CICD 在 Server Component 内调 `getExamById` + `getQuestions`,把 `exam.structure` + `questions` 转换为 `EditorDoc``examNodesToEditorDoc` + `structureToEditorDoc`portal-shell 完全无此逻辑
-**题目占位符插入** — CICD 富文本支持插入题目节点(含 order/options/contentportal-shell 仅纯文本 HTML
-**服务端初始数据预加载** — CICD Server Component 预加载 exam + questionsportal-shell 纯客户端 MSW
-**编辑模式 (mode="edit")** — CICD `ExamRichForm` 支持 create/edit 双模式portal-shell 仅 edit
- ⚠️ **dangerouslySetInnerHTML** — portal-shell 用 `dangerouslySetInnerHTML` 注入 HTML违反 §4 安全规范(如必须使用,先用 DOMPurify 清洗)
- **技术差距**
- CICDServer Component + Tiptap + `examNodesToEditorDoc`/`structureToEditorDoc` 转换层 + `ExamRichForm`
- portal-shell 需补:引入 Tiptap或 Lexical、编写 structure→EditorDoc 转换、Server Component 预加载、题目节点类型、DOMPurify 清洗
#### 3.2 `/shell/teacher/exams/grading`(阅卷列表占位
### 3.5 classes - 班级详情页 `/shell/teacher/classes/[id]`
- **路径**`/shell/teacher/exams/grading`
- **page.tsx 实现**
- **路径**`/shell/teacher/classes/[id]`(对应 CICD `/classes/my/[id]`
- **portal-shell 实现**`classes/[id]/page.tsx` + `features/teacher/classes/class-detail-client.tsx`
- **CICD 实现**`classes/my/[id]/page.tsx` + 5 个 widget 组件
- **已有功能**
- ✅ 真实查询 `useClassInfo(classId)`schema 已就绪)
- 基本信息区name/gradeId/headTeacherId/description/createdAt/updatedAt
- 学生名单区studentNo/name/enrolledAtMSW 兜底)
- 任课老师区name/subjectName/roleMSW 兜底)
- **缺失功能**
-**ClassHeader** — CICD 有独立头部组件classId/name/grade/homeroom/room/schoolName/studentCount
-**ClassOverviewStats 概览统计** — 平均分、提交率、待批改数papersToGrade、逾期数overdueCount
-**ClassTrendsWidget 趋势 widget** — 作业趋势列表id/title/status/subject/isActive/isOverdue/dueAt/submittedCount/targetCount/avgScore/medianScore
-**ClassScheduleWidget 课表 widget** — 班级课表展示
-**ClassAssignmentsWidget 作业 widget** — 班级作业列表
-**学生科目成绩展示** — CICD 通过 `getClassStudentSubjectScoresV2` 拉取每个学生的科目成绩portal-shell 学生表只有 studentNo/name/enrolledAt
-**三栏布局** — CICD 是 2/3 主区 + 1/3 侧栏portal-shell 是单栏 DetailSection 堆叠
-**服务端并行数据预加载** — CICD `Promise.all([getClassHomeworkInsights, getClassStudents, getClassSchedule, getClassStudentSubjectScoresV2])`
- **技术差距**
- CICDServer Component + 4 个并行 data-access 查询 + 5 个 widget 组件 + `SectionErrorBoundary`
- portal-shell 需补5 个 widget 组件、4 个 data-access 查询(含 MSW 兜底)、三栏布局、概览统计计算逻辑
```tsx
import { ListPageShell } from "@/shared/components/page-templates";
### 3.6 students - 学生列表页 `/shell/teacher/students`
export default function ExamGradingPage(): React.ReactElement {
return (
<ListPageShell
title="阅卷列表"
description="查看待批阅的考试提交"
empty
/>
);
}
```
- **路径**`/shell/teacher/students`(对应 CICD `/classes/students`
- **portal-shell 实现**`students/page.tsx` + `features/teacher/students/students-list-client.tsx`
- **CICD 实现**`classes/students/page.tsx` + `StudentsFilters` + `StudentsTable` + `students-filters.tsx`
- **已有功能**
- 关键词搜索q、classId/gradeId 筛选(**纯 text input**
- 表格studentNo/name/gender/className/gradeId/enrolledAt
- 性别徽章、EmptyState、ListPageShell
- **缺失功能**
-**班级下拉选择器** — CICD 用 `StudentsFilters` 组件(含 `<select>` 班级列表portal-shell 用 `<input type="text">` 让用户手输 classId
-**默认班级选择逻辑** — CICD 默认选中教师第一个班级(`defaultClassId = classes[0].id`portal-shell 无默认
-**学生科目成绩展示** — CICD 调 `getStudentsSubjectScores(studentIds)` 拉取每个学生科目成绩,`StudentsTable` 有科目成绩列portal-shell 无
-**服务端学生列表预加载** — CICD Server Component 调 `getClassStudents` + `getStudentsSubjectScores`portal-shell 纯客户端 MSW
-**状态筛选** — CICD 有 status 维度筛选active/inactive 等portal-shell 无
- **技术差距**
- CICDServer Component + `getTeacherClasses` + `getClassStudents` + `getStudentsSubjectScores` + `StudentsFilters`/`StudentsTable` 组件
- portal-shell 需补:班级 select 组件、默认班级逻辑、科目成绩列、status 筛选
- **当前状态**:🟡 占位空态,仅 `ListPageShell` + `empty` 属性,无 children无 client 组件。
- **已有但未集成的组件**:❌ 无。`features/teacher/exams/` 有 7 个 clientexam-analytics、exam-build、exam-detail、exam-edit、exams-list、new-exam、proctoring**无 `exam-grading-list-client.tsx`**。
- **可用资源**
- lib/api`lib/api/exams.ts` 有相关 hook`lib/api/homework.ts` 有 `useHomeworkSubmissions` hook
- mocks`graphql-data.ts` 有 `HomeworkSubmissions`、`ExamDetail` 等 mock 数据集
- CICD 行为CICD `exams/grading/page.tsx` 仅 `redirect("/teacher/homework/submissions")`,无实际功能
- **需要补全的内容**
1. **方案 A与 CICD 一致)**:改为 `redirect("/shell/teacher/homework/submissions")` 重定向到作业提交列表
2. **方案 B实现独立阅卷入口**:新建 `features/teacher/exams/exam-grading-list-client.tsx`,展示待批阅的考试提交(区分于作业提交)
- **建议**:方案 A重定向。与 CICD 行为一致,避免重复实现;如产品需要独立的"考试阅卷"入口(区分于作业批改),再按方案 B 补做。
### 3.7 homework - 作业详情页 `/shell/teacher/homework/[id]`
#### 3.3 `/shell/teacher/exams/grading/[submissionId]`(阅卷详情占位)
- **路径**`/shell/teacher/homework/[id]`(对应 CICD `/homework/assignments/[id]`
- **portal-shell 实现**`homework/[id]/page.tsx` + `features/teacher/homework/homework-detail-client.tsx`
- **CICD 实现**`homework/assignments/[id]/page.tsx` + `HomeworkAssignmentQuestionErrorOverviewCard` + `HomeworkAssignmentExamContentCard`
- **已有功能**
- ✅ 真实查询 `useHomework(homeworkId)`schema 已就绪)
- 基本信息区title/description/dueDate/gracePeriod/status/classId/subjectId/createdBy/updatedAt
- 提交列表区studentName/studentNo/status/submittedAt/totalScore/maxScore/actionsMSW 兜底)
- 内联批改表单studentId/score/feedbackMSW 兜底)
- 跳转"查看所有提交"按钮 → `/homework/assignments/[id]/submissions`
- **缺失功能**
-**头部统计行** — CICD 头部有 dueDate/targetCount/submissionCount/gradedCount 四个统计指标portal-shell 无
-**HomeworkAssignmentQuestionErrorOverviewCard 题目错误概览卡片** — CICD 用 `getHomeworkAssignmentAnalytics(id)` 拉取每题错误率,渲染分析卡片(带 Suspense + SectionErrorBoundary
-**HomeworkAssignmentExamContentCard 作业内容卡片** — CICD 渲染 `assignment.structure` + `questions`(含 gradedSampleCountportal-shell 无作业内容展示
-**服务端 analytics 预加载** — CICD 调 `getHomeworkAssignmentAnalytics(id)` 返回 `{ assignment, questions, gradedSampleCount }`
-**流式渲染** — CICD 用 `<Suspense fallback={<AnalyticsSkeleton />}>` 包裹 analytics 卡片portal-shell 无
- **技术差距**
- CICDServer Component + `getHomeworkAssignmentAnalytics` + 2 个分析卡片组件 + `SectionErrorBoundary` + Suspense 流式
- portal-shell 需补题目错误概览卡片、作业内容卡片structure + questions 渲染、头部统计、analytics 查询
- **路径**`/shell/teacher/exams/grading/[submissionId]`
- **page.tsx 实现**
### 3.8 homework - 布置作业表单 `/shell/teacher/homework/new`
```tsx
import { DetailPageShell } from "@/shared/components/page-templates";
- **路径**`/shell/teacher/homework/new`(对应 CICD `/homework/assignments/create`
- **portal-shell 实现**`homework/new/page.tsx` + `features/teacher/homework/new-homework-client.tsx`
- **CICD 实现**`homework/assignments/create/page.tsx` + `HomeworkAssignmentForm`
- **已有功能**
- 简单表单classId/subjectId/title/description/dueDate/gracePeriod
- 内联校验、MSW 兜底 mutation `useAssignHomework`
- 成功后回列表
- **缺失功能**
-**关联考试选择** — CICD Server Component 调 `getExams({ scope })` 拉取考试列表,传给 `HomeworkAssignmentForm` 作下拉选项作业可基于试卷结构生成portal-shell 无 examId 字段
-**班级下拉选择器** — CICD 调 `getTeacherClasses()` 拉取班级portal-shell 用 `<input type="text">` 手输 classId
-**EmptyState when no classes** — CICD 在 `classes.length === 0` 时显示 EmptyState 提示"无可用班级,去班级管理"portal-shell 无
-**HomeworkAssignmentForm 富表单** — CICD 用专门组件(含 exam 关联、班级 select、可能含题目结构选择
- **技术差距**
- CICDServer Component + `getExams` + `getTeacherClasses` + `HomeworkAssignmentForm` 组件 + EmptyState
- portal-shell 需补examId 字段 + 考试下拉、班级下拉、无班级 EmptyState
/**
* 阅卷详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:使用 DetailPageShell 直接渲染。
* 通过 /shell/teacher/exams/grading/[submissionId] 路由定位具体提交,
* 后续可接入阅卷专属 client component 进行逐题评分。
*
* 数据契约submission(id) schema 无, MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ExamGradingDetailPage(): React.ReactElement {
return (
<DetailPageShell
title="阅卷详情"
description="批阅学生考试提交"
backHref="/shell/teacher/exams/grading"
/>
);
}
```
### 3.9 lesson-plans - 教案编辑工作台 `/shell/teacher/lesson-plans/[planId]/edit`
- **当前状态**:🟡 占位空态,仅 `DetailPageShell` + `title/description/backHref`,无 children无 client 组件。
- **已有但未集成的组件**:❌ 无。`features/teacher/exams/` 无阅卷详情 client阅卷能力当前在 `features/teacher/homework/submission-grading-client.tsx`(已被 `/homework/submissions/[submissionId]` 集成)。
- **可用资源**
- lib/api`lib/api/homework.ts` 有 `useSubmissionDetail(submissionId)` / `useGradeSubmission` hook
- mocks`graphql-data.ts` 有 `SubmissionDetail`、`HomeworkSubmissions` mock 数据集
- 同域可复用:`/homework/submissions/[submissionId]/page.tsx` 已实现 `SubmissionGradingClient`(含逐题评分、批注、自动判分、保存草稿)
- CICD 行为CICD `exams/grading/[submissionId]/page.tsx` 仅 `redirect("/teacher/homework/submissions")`注意CICD 未保留 submissionId 参数,会丢失上下文)
- **需要补全的内容**
1. **方案 A推荐优于 CICD**:改为重定向到 `/shell/teacher/homework/submissions/[submissionId]`,保留 submissionId 参数(修复 CICD 丢失参数的问题)
2. **方案 B实现独立阅卷详情**:新建 `features/teacher/exams/exam-grading-detail-client.tsx`,对接 `useSubmissionDetail` hook 实现考试阅卷专属视图(区别于作业批改)
- **建议**:方案 A重定向并保留参数。与 CICD 行为一致但修复其缺陷;如产品需要独立的考试阅卷详情视图(区分于作业批改),再按方案 B 补做。
- **路径**`/shell/teacher/lesson-plans/[planId]/edit`
- **portal-shell 实现**`lesson-plans/[planId]/edit/page.tsx` + `features/teacher/lesson-plans/lesson-plan-edit-client.tsx`
- **CICD 实现**`lesson-plans/[planId]/edit/page.tsx` + `LessonPlanEditor` + `LessonPlanProviderSetup` + `AiClientProvider`
- **已有功能**
- 三栏工作台左栏大纲树LessonPlanOutlineTree+ 中栏富文本编辑器 + 右栏属性面板id/grade/subject/duration/status/updatedAt/resources
- 工具栏B/I/U/H1/H2/H3/列表/P
- `useLessonPlan` + `useUpdateLessonPlan` MSW 兜底
- 保存为 HTML
- **缺失功能**
-**真正的 Tiptap 编辑器** — portal-shell 用 `contentEditable` + `document.execCommand`**deprecated**CICD 用 `LessonPlanEditor`Tiptap
-**AI 集成** — CICD 包 `<AiClientProvider service={createCoreAiClientService()}>`,支持 AI 辅助生成/润色portal-shell 无
-**教材/章节上下文** — CICD Server Component 调 `getTextbookById` + `getChaptersByTextbookId` + `findChapterById`,把 textbookTitle/chapterTitle 传给编辑器工具栏显示portal-shell 无
-**LessonPlanProviderSetup** — CICD 有专门的 Provider 设置组件注入编辑器所需上下文portal-shell 无
-**服务端初始数据预加载** — CICD Server Component 预加载 plan + teacherClasses + textbook + chapterportal-shell 纯客户端 MSW
-**班级关联** — CICD 加载 `getTeacherClasses({ teacherId })` 传给编辑器教案可关联班级portal-shell 无
- ⚠️ **dangerouslySetInnerHTML** — 同 §3.4,违反 §4 安全规范
- **技术差距**
- CICDServer Component + Tiptap + AiClientProvider + LessonPlanProviderSetup + 4 个 data-access 查询
- portal-shell 需补Tiptap、AiClientProvider 接入、教材/章节加载、Provider 设置、班级关联、DOMPurify 清洗
### 三.A 小结:占位页共性特征
### 3.10 grades - 成绩录入页 `/shell/teacher/grades/entry`
3 个占位页的共性:
- **路径**`/shell/teacher/grades/entry`
- **portal-shell 实现**`grades/entry/page.tsx` + `features/teacher/grades/grade-entry-client.tsx`
- **CICD 实现**`grades/entry/page.tsx` + `BatchGradeEntryByExam` 组件
- **已有功能**
- **单学生单次录入**表单classId/studentId/examId/homeworkId/score/totalScore/feedback
- 内联校验、MSW 兜底 mutation `useCreateGrade`
- **缺失功能**
-**批量录入** — CICD 用 `BatchGradeEntryByExam` 组件,按考试批量录入全班成绩(一次选 exam + class列出所有学生逐题/逐生录入portal-shell 是单生单次
-**考试下拉选择器** — CICD 调 `getExamsForGradeEntry(ctx.dataScope)` 拉取可录入成绩的考试列表portal-shell 用 `<input type="text">` 手输 examId
-**班级下拉选择器** — CICD 调 `getTeacherClasses()` + scope 过滤 + `getClassGradeIdsByClassIds` 构建 classGradeMap用于按试卷年级过滤班级portal-shell 手输 classId
-**学生列表预加载** — CICD 调 `getClassStudentsForEntry(classId, ctx.dataScope)` 拉取班级学生portal-shell 手输 studentId
-**考试题目列表** — CICD 调 `getExamForGradeEntry(examId, ctx.dataScope)` 返回试卷详情含题目,支持按题录入
-**DataScope 过滤** — CICD 用 `ctx.dataScope.type === "class_taught"` 过滤班级portal-shell 无权限过滤
-**EmptyState when no exams** — CICD 在 `exams.length === 0` 时显示 EmptyState
-**按试卷年级过滤班级** — CICD 用 classGradeMap 客户端过滤
- **技术差距**
- CICDServer Component + 4 个 data-access 查询 + `BatchGradeEntryByExam` 客户端组件 + DataScope
- portal-shell 需补:批量录入组件、考试/班级下拉、学生列表、题目列表、scope 过滤、EmptyState
### 3.11 attendance - 考勤点名表 `/shell/teacher/attendance/sheet`
- **路径**`/shell/teacher/attendance/sheet`
- **portal-shell 实现**`attendance/sheet/page.tsx` + `features/teacher/attendance/attendance-sheet-client.tsx`
- **CICD 实现**`attendance/sheet/page.tsx` + `AttendanceSheet` 组件
- **已有功能**
- classId**text input**+ date 选择
- 学生表格studentName/status 按钮组/remark
- 4 种状态PRESENT/ABSENT/LATE/LEAVE点击切换
- MSW 兜底 `useAttendanceSheet` + `useSaveAttendanceSheet`
- 保存后回列表
- **缺失功能**
-**班级下拉选择器** — CICD Server Component 调 `getTeacherClasses()` 拉取班级,传给 `AttendanceSheet` 组件作下拉portal-shell 用 `<input type="text">` 手输 classId
-**服务端学生列表预加载** — CICD 调 `getClassStudentsForAttendance(defaultClassId)` 服务端预加载学生portal-shell 完全依赖 MSW mock
-**AttendanceSheet 组件** — CICD 有专门的客户端组件含班级切换、日期选择、批量标记、统计汇总portal-shell 是手写表格
- **技术差距**
- CICDServer Component + `getTeacherClasses` + `getClassStudentsForAttendance` + `AttendanceSheet` 客户端组件
- portal-shell 需补:班级 select、服务端学生预加载、AttendanceSheet 组件
### 3.12 diagnostic - 班级诊断详情 `/shell/teacher/diagnostic/class/[classId]`
- **路径**`/shell/teacher/diagnostic/class/[classId]`
- **portal-shell 实现**`diagnostic/class/[classId]/page.tsx` + `features/teacher/diagnostic/diagnostic-class-detail-client.tsx`
- **CICD 实现**`diagnostic/class/[classId]/page.tsx` + `ClassDiagnosticView` + `DiagnosticServiceProvider` + `DiagnosticMonitorProvider`
- **已有功能**
- 基本信息区title/reportType/status/className/studentCount/avgScore/summary/generatedAt
- 薄弱知识点表格kpTitle/mastery/errorCount
- 教学建议列表recommendations
- MSW 兜底 `useDiagnosticReport(classId)`
- **缺失功能**
-**ClassDiagnosticView 富视图组件** — CICD 用专门的 `ClassDiagnosticView`(含掌握度雷达图、知识点 drill-down、学生分布等丰富交互portal-shell 是基础详情页
-**DiagnosticServiceProvider** — CICD 注入诊断服务上下文(支持客户端 drill-down 查询portal-shell 无
-**DiagnosticMonitorProvider + 监控埋点** — CICD 用 `createMonitoredDiagnosticService(defaultDiagnosticService, noopDiagnosticMonitor)` 包装服务支持生产环境监控注入portal-shell 无
-**服务端数据预加载** — CICD Server Component 调 `getClassMasterySummary(classId)` 预加载portal-shell 纯客户端 MSW
-**DataScope 校验** — CICD 校验 `class_taught` scope教师只能查所教班级拒绝 `class_members`/`children` scopeportal-shell 无
-**WidgetBoundary** — CICD 用 `WidgetBoundary` 包裹局部降级portal-shell 用 DetailPageShell
- **技术差距**
- CICDServer Component + `getClassMasterySummary` + `ClassDiagnosticView` + Diagnostic Service Provider/Monitor + DataScope
- portal-shell 需补ClassDiagnosticView 组件、DiagnosticServiceProvider/MonitorProvider、服务端预加载、DataScope 校验、WidgetBoundary
### 3.13 dashboard - 教师仪表盘 `/shell/teacher`teacher/page.tsx
- **路径**`/shell/teacher`(根教师页)
- **portal-shell 实现**`shell/teacher/page.tsx`"use client"
- **CICD 实现**`dashboard/page.tsx`Server Component+ `TeacherDashboardView`
- **已有功能**
- 4 StatCardtotal_classes/total_students/class_avg_score/pending_homework_count
- 班级概况区class_name/student_count/average_score
- 近期预警区target_name/severity/warning_type/current_value/threshold
- ✅ 真实查询 `useTeacherDashboard`data-ana 聚合查询)
- loading/error 三态处理
- **缺失功能**
- ⚠️ **i18n 硬编码** — portal-shell 标题/描述用硬编码中文(`"教师仪表盘"``"今日教学概览"``"班级总数"` 等),违反 §3.10 设计令牌规范的 i18n 要求,应改 `useTranslations("dashboard")`CICD 用 `getTranslations("dashboard")`
-**流式渲染** — CICD Server Component 用 `getTeacherDashboardAction()` 返回未解析 Promise + `<TeacherDashboardView dataPromise={...}>`,视图内 `use()` 消费 + Suspense 流式渲染portal-shell 是客户端整体 loading
-**TeacherDashboardView 富视图** — CICD 用专门的 `TeacherDashboardView` 组件(可能含更多 widget课表/作业/考试/学生预警等portal-shell 仅 4 StatCard + 2 简单列表
-**更多 widget** — CICD 可能含课表、作业待批、考试安排、学生预警等 widget需读 TeacherDashboardView 确认portal-shell 仅基础展示
- **技术差距**
- CICDServer Component + Server Action + Suspense 流式 + `TeacherDashboardView` 组件
- portal-shell 需补i18n 化、TeacherDashboardView 组件、流式渲染、更多 widget待确认
### 3.14 classes - 班级列表页 `/shell/teacher/classes`(轻量不完整)
- **路径**`/shell/teacher/classes`
- **portal-shell 实现**`classes/page.tsx` + `features/teacher/classes/classes-list-client.tsx`
- **CICD 实现**`classes/my/page.tsx` + `MyClassesGrid` 组件
- **已有功能**
- ✅ 真实查询 `useClasses({ gradeId, subjectId, q })`MSW 兜底)
- 三筛选q/gradeId/subjectId、表格name/grade/headTeacher/studentCount/subjectCount/description/updatedAt/actions
- EmptyState、ListPageShell
- **缺失功能**
- ⚠️ **MyClassesGrid 卡片网格视图** — CICD 用 `MyClassesGrid` 组件卡片式展示portal-shell 用表格,视觉差异(功能等价,不算重大缺失)
- ⚠️ **subjectOptions 预加载** — CICD 调 `getClassSubjects()` 拉取科目选项作下拉portal-shell 用 text input
- ⚠️ **SectionErrorBoundary** — CICD 用 `SectionErrorBoundary` 包裹portal-shell 用 errorNode
- **技术差距**:较小,主要是视觉/组件差异,功能基本等价
### 3.15 exams - 试卷组卷工作台 `/shell/teacher/exams/[id]/build`
- **路径**`/shell/teacher/exams/[id]/build`
- **CICD 参考实现**`e:\desktop\CICD\src\app\(dashboard)\teacher\exams\[id]\build\page.tsx` + 同目录 `error.tsx` + `loading.tsx`
- **功能描述**试卷组卷工作台CICD 为 Server Component 入口(`export const dynamic = "force-dynamic"`
- `requirePermission(Permissions.EXAM_READ)` 权限校验,拿到 `ctx.dataScope`
- `getExamById(id, ctx.dataScope)` 拉取考试元数据(含 `questions` + `structure`),不存在则 `notFound()`
- `Promise.all` 并行拉取题库前 20 条(`getQuestions({ pageSize: 20 })`+ 已选题目详情(`getQuestions({ ids: selectedQuestionIds })`
- `questionOptionsById` Map 去重合并 bank + selected 题目为 `questionOptions`
- `normalizeStructure(exam.structure)``exam.structure` 规范化为 `ExamNode[]` 树(支持嵌套分组节点)
- structure 空但 `initialSelected` 非空时回退为扁平节点列表(用 `@paralleldrive/cuid2` `createId()` 生成节点 ID
- `createCoreAiClientService()` 创建 AI 服务,`<AiClientProvider>` 注入 AI 上下文
- 渲染 `<ExamAssembly>` 富组件(位于 `modules/exams/components/exam-assembly`),透传 examId/title/subject/grade/difficulty/totalScore/durationMin + initialSelected + initialStructure + questionOptions
- 配套 `error.tsx``useErrorReport` 错误上报 + `EmptyState` + retry 按钮)+ `loading.tsx`3 栏 Skeleton`lg:grid-cols-3` 2+1 布局)
- **技术栈**Next.js App Router Server Component`force-dynamic`+ next-intl `getTranslations("examHomework.exam.build")` + `requirePermission` + `Permissions.EXAM_READ` + `ctx.dataScope` 数据范围过滤 + Drizzle data-access`getExamById` / `getQuestions`+ `normalizeStructure` 转换层 + `@paralleldrive/cuid2` + AI 集成(`AiClientProvider` + `createCoreAiClientService`+ `ExamAssembly` 客户端组件 + 专用 error/loading 边界
- **ARCHITECTURE.md 契约**B2— portal-shell `page.tsx` 注释明确 `@contract-pending``examBuild(examId)` / `questionsLibrary(filter)` / `saveExamBuild(input)` 三个根字段均不存在,全 MSW 兜底
- **portal-shell 现状**
- `page.tsx``<Suspense fallback={<WorkbenchPageSkeleton />}>` 包裹 `ExamBuildClient`**无服务端预加载、无权限守卫、无 notFound**
- `ExamBuildClient` 纯客户端,使用 `useExamBuild` / `useQuestionsLibrary` / `useSaveExamBuild` 三个 Apollo hook全 MSW 兜底)
- `WorkbenchPageShell` 三栏布局:左 `QuestionsLibraryPanel` / 中 `SelectedQuestionsPanel` / 右 `PreviewPanel`
- 左栏q 关键词 + type 下拉 + difficulty 下拉筛选 + 题目列表 + 添加按钮(`Plus` 图标)+ 总数显示
- 中栏:已选题目列表,支持上移/下移(`↑`/`↓`/编辑分值(`Input type="number"`/移除(`Trash2`
- 右栏:总分(含 baseline 差值 ± 提示amber/destructive 配色)+ 及格分 + 题型分布(`countByType`
- 本地 `useState<ExamBuildNode[] | null>` 管理 selected`useEffect` 在 data 首次就绪时初始化(`sortBySortOrder`
- 保存 mutation `useSaveExamBuild` + `notify.success/error` 反馈
- 三态合并到 `errorNode`error + empty 共用降级节点)
- **无专用 error.tsx / loading.tsx 文件**
- **缺失功能**
-**Server Component 数据预加载** — CICD 在服务端并行 `Promise.all` 拉取 exam + bank questions + selected questionsportal-shell 完全依赖客户端 MSW
-**DataScope 权限过滤** — CICD 用 `ctx.dataScope` 过滤考试可见性portal-shell 无
-**`requirePermission(Permissions.EXAM_READ)` 权限守卫** — portal-shell 无
-**`notFound()` 404 处理** — CICD 考试不存在时调 `notFound()`portal-shell 显示空态节点
-**AI 集成** — CICD 包 `<AiClientProvider service={createCoreAiClientService()}>` 支持 AI 辅助组卷portal-shell 无 AI 接入(与 §3.9 教案编辑同类型缺失,见 §六 共性技术债 6
-**`normalizeStructure` 转换层** — CICD 把 `exam.structure` 规范化为 `ExamNode[]`支持嵌套分组节点portal-shell 用扁平 `ExamBuildNode[]`,无结构化分组能力
-**structure 空时回退逻辑** — CICD 在 structure 空但 `initialSelected` 非空时回退为扁平节点列表cuid2 生成 IDportal-shell 无此兼容
-**`ExamAssembly` 富组件** — CICD 用专门的 `ExamAssembly` 组件(`modules/exams/components/exam-assembly`portal-shell 是手写 3 个 Panel 函数
-**questionOptions 去重合并** — CICD 把 bank + selected 题目通过 Map 去重合并为 `questionOptions` 传给组件portal-shell 左右栏分别查询、无合并
-**专用 error.tsx** — CICD 有 `error.tsx``useErrorReport` 上报 + `EmptyState` + retry 按钮portal-shell 用 `WorkbenchPageShell.errorNode` 整页降级,无错误上报
-**专用 loading.tsx** — CICD 有 3 栏 Skeleton`lg:grid-cols-3` 2+1 布局portal-shell 用通用 `WorkbenchPageSkeleton`
-**`dynamic = "force-dynamic"`** — CICD 强制动态渲染权限相关数据不能静态化portal-shell 默认
- ⚠️ **i18n 命名空间差异** — CICD 用 `examHomework.exam.build`portal-shell 用 `exams`,需对齐
- ⚠️ **exam 元数据透传** — CICD 把 subject/grade/difficulty/durationMin 传给 `ExamAssembly` 展示portal-shell 不展示这些上下文
- **建议**
1. **短期(契约未就绪)**:保留 MSW 兜底;补 `error.tsx``useErrorReport` + EmptyState + retry+ `loading.tsx`3 栏 Skeletoni18n 命名空间与 CICD 对齐(或保留 `exams` 但与 ARCH §11 一致)
2. **中期(契约就绪后)**
- 改造为 Server Component并行预加载 exam + questionsbank + selected
- 引入 `requirePermission` + `ctx.dataScope` 过滤
- 实现 `normalizeStructure` 转换层,支持嵌套 `ExamNode` 树(与 CICD 对齐)
- 引入 `AiClientProvider` + `createCoreAiClientService`,接入 AI 辅助组卷
- 抽取 `ExamAssembly` 富组件,统一 3 栏 UI 与 CICD 对齐
- questionOptions 去重合并,减少客户端重复查询
3. **长期**:与 §3.4exams/[id]/edit 富文本)+ §3.9lesson-plans edit统一引入 Tiptap + AI 集成方案(见 §六 共性技术债 1/2/6
1. **路径来源**均为 CICD 单体中的重定向占位路径portal-shell 迁移时保留了路径但未集成业务
2. **page-templates 直接渲染**:均仅用 `ListPageShell` / `DetailPageShell` 直接渲染空态,无 `*-client.tsx` 集成
3. **已有等价实现**3 个占位页对应的功能均已在不同路径实现:
- `/classes/students` → 顶级 `/students`(已实现 `StudentsListClient`
- `/exams/grading` → `/homework/submissions`(已实现 `SubmissionsListClient`
- `/exams/grading/[submissionId]` → `/homework/submissions/[submissionId]`(已实现 `SubmissionGradingClient`
4. **推荐统一处理**3 个占位页均建议改为 `redirect()` 重定向到等价路径,避免重复实现
5. **优先级**P0占位页影响用户体验路径存在但无内容
---
## 四、按模块汇总
### 三.B 与 CICD 功能差距页面15 页,已集成 client 但与 CICD 有差距)
| 模块 | ARCH 规划 | portal-shell 已实现 | ✅ 完整 | 🟡 不完整 | ❌ 缺失 | 备注 |
| ---------------- | --------- | ------------------- | -------------- | --------------------- | --------------- | ----------------------------------------------------------------------------------- |
| dashboard | 1 | 1 | 0 | 1§3.13 | 0 | i18n 硬编码 + 缺流式 + 缺富视图 |
| exams | 7 | 6 | 1analytics | 4§3.1/3.2/3.3/3.4 | 1proctoring | build 列入🟡但功能基本完整,缺 AI |
| homework | 7 | 7 | 5 | 2§3.7/3.8 | 0 | 详情缺分析卡片,新建缺考试关联 |
| grades | 5 | 5 | 4 | 1§3.10 | 0 | entry 缺批量录入 |
| lesson-plans | 6 | 6 | 5 | 1§3.9 | 0 | edit 缺 Tiptap + AI + 教材章节 |
| questions | 1 | 1 | 1 | 0 | 0 | — |
| textbooks | 2 | 2 | 2 | 0 | 0 | — |
| attendance | 4 | 4 | 3 | 1§3.11 | 0 | sheet 缺班级下拉 + 学生预加载 |
| classes | 3 | 3 | 1schedule | 2§3.5/3.14 | 0 | [id] 缺 5 个 widgetlist 轻量差异 |
| students | 1 | 1 | 0 | 1§3.6 | 0 | 缺下拉 + 科目成绩 |
| course-plans | 2 | 2 | 2 | 0 | 0 | — |
| elective | 3 | 3 | 3 | 0 | 0 | — |
| error-book | 1 | 1 | 1 | 0 | 0 | — |
| diagnostic | 2 | 2 | 1list | 1§3.12 | 0 | class/[classId] 缺富视图 + 服务上下文 |
| analytics | 2 | 2 | 2 | 0 | 0 | ARCH 新建MSW 兜底 |
| ai | 3 | 3 | 3 | 0 | 0 | ARCH 新建 B2末全 MSW |
| knowledge-graph | 1 | 1 | 1 | 0 | 0 | ARCH 新建 B2末 |
| practice | 1 | 1 | 1 | 0 | 0 | — |
| schedule-changes | 1 | 1 | 1 | 0 | 0 | — |
| leave | 1 | 1 | 1 | 0 | 0 | — |
| notifications | 1共享 | 0 | 0 | 0 | 1§2.3 | B1 共享schema ✅ |
| settings | 1共享 | 0 | 0 | 0 | 1§2.4 | B1 共享 |
| **合计** | **56** | **53** | **33** | **14** | **4** | 另CICD 历史功能 `diagnostic/student/[studentId]` 未在 ARCH 规划建议补做§2.2 |
> 以下 15 页**均已集成 `*-client.tsx`**,按本次严格三态定义归入 ✅ 完整实现。但与 CICD 单体相比仍存在功能差距(如缺少某些子功能、操作按钮、数据维度等),保留原核查的详细对比分析供后续迭代参考。
>
> **优先级**P1-P2功能增强非阻塞性
>
> **说明**:本节为原 §3.1-§3.15 的内容保留归类。由于 60 页均已完成页面骨架与数据流闭环,本节作为后续迭代的功能增强 backlog不再视为占位/缺失。详细页面级差距分析详见原核查记录2026-07-24与 CICD 源码对比,本次核查不重复展开,仅列清单:
| 序号 | 页面路径 | 集成的 client | 与 CICD 主要差距概述 |
| ---- | ------------------------------------------- | ----------------------- | ---------------------------------------------------------------- |
| 1 | `/shell/teacher`dashboard | dashboard-cards-client | CICD 7 张卡片含交互态portal-shell 已对齐 7 卡 + i18n差距较小 |
| 2 | `/shell/teacher/exams` | exams-list-client | 列表筛选维度、状态筛选、批量操作 |
| 3 | `/shell/teacher/exams/[id]` | exam-detail-client | 详情页子 Tab 数量、阅卷入口跳转 |
| 4 | `/shell/teacher/exams/new` | new-exam-client | 表单步骤、题型选择、知识点绑定 |
| 5 | `/shell/teacher/exams/[id]/edit` | exam-edit-client | 编辑权限点、草稿恢复 |
| 6 | `/shell/teacher/exams/[id]/build` | exam-build-client | 组卷策略、难度校准 |
| 7 | `/shell/teacher/classes/[id]` | class-detail-client | 详情子 Tab、学生名单 inline |
| 8 | `/shell/teacher/classes` | classes-list-client | 列表筛选、班级切换 |
| 9 | `/shell/teacher/students` | students-list-client | 学生筛选、详情抽屉 |
| 10 | `/shell/teacher/homework/[id]` | homework-detail-client | 作业详情 Tab、提交统计 |
| 11 | `/shell/teacher/homework/new` | new-homework-client | 作业类型、附件上传 |
| 12 | `/shell/teacher/lesson-plans/[planId]/edit` | lesson-plan-edit-client | 编辑器富文本、模板插入 |
| 13 | `/shell/teacher/grades/entry` | grade-entry-client | 成绩录入批量、导入导出 |
| 14 | `/shell/teacher/attendance/sheet` | attendance-sheet-client | 考勤表批量、快捷标记 |
| 15 | `/shell/teacher/diagnostic/class/[classId]` | class-diagnostic-client | 诊断维度、对比分析 |
> **后续处理建议**:本节 15 页的功能差距属 P1-P2 增强项,建议按产品优先级逐模块迭代补齐;每次补齐后运行 `pnpm run arch:scan` 同步 arch.db并视情况更新 004 与本核查文档。
---
## 五、优先级建议
## 四、配套资产覆盖核查
### P0B1 共享必须补schema 已就绪
### 4.1 features/teacher/*-client.tsx 覆盖55 个页面级 client
1. `/shell/notifications` — B1 共享,`notifications(userId)`
2. `/shell/settings` — B1 共享
详见 §七.2(附录)。覆盖 20 个教师域模块,所有 ✅ 完整实现页均有对应 client 集成。
### P1B2末 规划页,必须补
### 4.2 lib/api hooks 覆盖20 个模块文件
3. `/shell/teacher/exams/[id]/proctoring` — B2末WS 二期,需先定 WS 契约
| 模块 | 文件 | 关键 hook |
| ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
| ai | `lib/api/ai.ts` | useAiAssist / useAiLessonPlan / useAiReport |
| analytics | `lib/api/analytics.ts` | useAnalytics / useStudentAnalytics |
| attendance | `lib/api/attendance.ts` | useAttendance / useAttendanceReport / useAttendanceSheet / useAttendanceStats |
| classes | `lib/api/classes.ts` | useClasses / useClassDetail / useClassStudents / useClassSchedule |
| course-plans | `lib/api/course-plans.ts` | useCoursePlans / useCoursePlanDetail |
| dashboard | `lib/api/dashboard.ts` | useTeacherDashboard |
| diagnostic | `lib/api/diagnostic.ts` | useClassDiagnostic / useStudentDiagnostic |
| elective | `lib/api/elective.ts` | useElectiveCourses / useElectiveDetail |
| error-book | `lib/api/error-book.ts` | useErrorBookItems / useErrorBookStats |
| exams | `lib/api/exams.ts` | useExams / useExamDetail / useExamAnalytics / useExamBuild / useExamProctoring |
| grades | `lib/api/grades.ts` | useGrades / useGradeEntry / useGradeStats / useReportCard |
| homework | `lib/api/homework.ts` | useHomework / useHomeworkDetail / useHomeworkSubmissions / useSubmissionDetail / useGradeSubmission |
| knowledge-graph | `lib/api/knowledge-graph.ts` | useKnowledgeGraph |
| leave | `lib/api/leave.ts` | useLeaveRequests |
| lesson-plans | `lib/api/lesson-plans.ts` | useLessonPlans / useLessonPlanDetail / useLessonPlanCalendar / useLessonPlanHeatmap |
| practice | `lib/api/practice.ts` | usePractice |
| proctoring | `lib/api/proctoring.ts` | useStudentProctoringStatuses / useRecentProctoringEvents |
| questions | `lib/api/questions.ts` | useQuestions |
| schedule-changes | `lib/api/schedule-changes.ts` | useScheduleChanges |
| students | `lib/api/students.ts` | useStudents / useStudentDetail |
| textbooks | `lib/api/textbooks.ts` | useTextbooks / useTextbookDetail |
### P2CICD 历史功能,建议补,需同步更新 ARCH §9.1
### 4.3 mocks/graphql-data.ts 覆盖116 个 mock 数据集
4. `/shell/teacher/diagnostic/student/[studentId]` — 学生诊断雷达图CICD 已有完整实现
### P3功能不完整页按业务优先级补
**高优先级(核心教学场景,差距大)**5. `classes/[id]` 班级详情 — 缺 5 个 widget概览/趋势/课表/作业/学生科目成绩班级是教师核心工作台6. `homework/[id]` 作业详情 — 缺分析卡片 + 内容卡片,作业是高频场景 7. `grades/entry` 成绩录入 — 缺批量录入,单生录入效率太低 8. `lesson-plans/[planId]/edit` 教案编辑 — 缺 Tiptap + AIcontentEditable deprecated 不可长期维护
**中优先级(交互不完整,影响体验)**9. `students` 学生列表 — 缺下拉 + 科目成绩 10. `homework/new` 布置作业 — 缺考试关联 + 班级下拉 11. `attendance/sheet` 考勤点名 — 缺班级下拉 + 学生预加载 12. `diagnostic/class/[classId]` 班级诊断 — 缺富视图 + 服务上下文 13. `exams/[id]/edit` 富文本编辑 — 缺 Tiptap + 题目结构(与 §3.9 教案编辑同类型问题,建议统一升级 Tiptap14. `exams` 列表 — 缺筛选/计数/分页/DataTable
**低优先级(功能基本可用,细节差距)**15. `exams/[id]` 详情 — 编辑按钮未接路由小修16. `exams/new` 新建考试 — 是否补富文本取决于产品决策 17. `classes` 列表 — 视觉差异,功能等价 18. `dashboard` 仪表盘 — i18n 化 + 流式渲染i18n 应立即修,违反 §3.10
269KB 文件覆盖教师域全部模块(含 3 个占位页的兜底数据:`ClassStudents`、`HomeworkSubmissions`、`SubmissionDetail`、`ExamDetail`)。所有占位页与功能差距页均有 MSW 兜底。
---
## 六、共性技术债
## 五、结论与建议
1. **Tiptap 缺失**`exams/[id]/edit``lesson-plans/[planId]/edit` 均用 `contentEditable` + `document.execCommand`deprecatedCICD 用 Tiptap。建议统一引入 Tiptap或 Lexical编写 structure→EditorDoc 转换层。
2. **dangerouslySetInnerHTML 违规**:上述两个富文本页直接 `dangerouslySetInnerHTML`,违反 §4 安全规范,需 DOMPurify 清洗。
3. **下拉选择器缺失**`students`/`homework/new`/`grades/entry`/`attendance/sheet` 等多处用 `<input type="text">` 替代 `<select>`,需补 data-access 查询 + select 组件。
4. **DataScope 校验缺失**portal-shell 普遍无 `ctx.dataScope` 权限过滤CICD 在 Server Component 内做 scope 过滤),需在后端契约就绪后补。
5. **Server Component 预加载缺失**CICD 多个详情页在 Server Component 内并行预加载多个数据源portal-shell 纯客户端 MSW等契约就绪后需改 Server Component 预加载 + Suspense 流式。
6. **AI 集成缺失**`exams/[id]/build``lesson-plans/[planId]/edit` 在 CICD 有 `AiClientProvider` + `createCoreAiClientService`portal-shell 无 AI 接入。
7. **i18n 硬编码**`dashboard` 用硬编码中文,应全面 `useTranslations`
8. **SectionErrorBoundary / WidgetBoundary 缺失**CICD 多处用 `SectionErrorBoundary`/`WidgetBoundary` 做局部降级portal-shell 用 errorNode 整页降级,粒度较粗。
### 5.1 总体结论
- 教师域 63 个 page.tsx **全部存在,无缺失**
- 60 页 ✅ 完整实现(集成 client + hooks + 三态处理)
- 3 页 🟡 占位空态(均为 CICD 重定向路径,建议改为 `redirect()` 重定向到等价已实现路径)
- 配套资产齐全55 个 client 组件 + 20 个 lib/api 模块 + 116 个 mock 数据集
### 5.2 优先级建议
| 优先级 | 项目 | 工作量 | 说明 |
| --------- | ---------------------------------- | ----------------- | --------------------------------------------------------------------------------------- |
| **P0** | 3 个占位页改为 `redirect()` 重定向 | 小(每页约 5 行) | 见 §3.1-§3.3 方案 A/B统一重定向到等价已实现路径 |
| **P1-P2** | 15 页与 CICD 功能差距补齐 | 中-大 | 见 §三.B按产品优先级逐模块迭代 |
| **P2** | 同步更新 ARCH §9.1 + 004 + arch.db | 小 | 补做 `diagnostic/student/[studentId]` 与 `exams/[id]/proctoring` 已实现但未同步规划文档 |
### 5.3 不需补做的项
- `/shell/notifications`、`/shell/settings`:属 B1 共享域(非教师域),需在共享域补页面,不在本次教师域核查范围
- `/exams/all`、`/exams/create`、`/classes/my`、`/classes/my/[id]`、`/homework/assignments/create`:为路径别名,复用同一 client不算缺失
---
## 七、参考文件索引
## 六、核查元信息
### CICD 参考实现
- `e:\desktop\CICD\src\app\(dashboard)\teacher\` — 教师域 53 页源
- `e:\desktop\CICD\src\modules\exams\components\exam-rich-form.tsx` — 富文本试卷表单
- `e:\desktop\CICD\src\modules\exams\components\exam-assembly.tsx` — 组卷工作台
- `e:\desktop\CICD\src\modules\classes\components\class-detail\` — 班级详情 5 个 widget
- `e:\desktop\CICD\src\modules\classes\components\my-classes-grid.tsx` — 班级网格
- `e:\desktop\CICD\src\modules\classes\components\students-table.tsx` — 学生表
- `e:\desktop\CICD\src\modules\homework\components\homework-assignment-question-error-overview-card.tsx` — 作业错误概览
- `e:\desktop\CICD\src\modules\homework\components\homework-assignment-exam-content-card.tsx` — 作业内容卡片
- `e:\desktop\CICD\src\modules\grades\components\batch-grade-entry.tsx` — 批量录入
- `e:\desktop\CICD\src\modules\lesson-preparation\components\lesson-plan-editor.tsx` — 教案编辑器
- `e:\desktop\CICD\src\modules\diagnostic\components\class-diagnostic-view.tsx` — 班级诊断视图
- `e:\desktop\CICD\src\modules\diagnostic\components\student-diagnostic-view.tsx` — 学生诊断视图
- `e:\desktop\CICD\src\modules\proctoring\components\proctoring-dashboard.tsx` — 监考面板
- `e:\desktop\CICD\src\modules\dashboard\components\teacher-dashboard\teacher-dashboard-view.tsx` — 仪表盘视图
### portal-shell 当前实现
- `e:\Desktop\Edu\apps\portal-shell\src\app\shell\teacher\` — 教师域 53 页
- `e:\Desktop\Edu\apps\portal-shell\src\features\teacher\` — 51 个 `*-client.tsx` + 辅助组件
- `e:\Desktop\Edu\apps\portal-shell\src\lib\api\` — Apollo hooks含 MSW 兜底)
- `e:\Desktop\Edu\apps\portal-shell\src\shared\components\page-templates\` — ListPageShell/FormPageShell/DetailPageShell/WorkbenchPageShell
- `e:\Desktop\Edu\apps\portal-shell\ARCHITECTURE.md` §9.1 — 教师域 56 页规划
### 契约工单
- `docs/architecture/issues/contracts/core-edu_contract.md` — exams/homework/grades/questions mutation 工单
- `docs/architecture/issues/contracts/classes_contract.md` — classes/students/attendance 工单
- `docs/architecture/issues/contracts/data-ana_contract.md` — diagnostic/analytics 工单
- **核查日期**2026-08-04
- **核查范围**`apps/portal-shell/src/app/shell/teacher/` 下全部 63 个 page.tsx
- **核查方式**:逐页面读取 page.tsx 文件内容PowerShell `Get-Content -LiteralPath`+ features/*-client.tsx 集成核查 + lib/api hooks 覆盖核查 + mocks/graphql-data.ts 覆盖核查
- **三态判据**:见 §0.1(✅ 引入 client 或含真实业务 / 🟡 仅 page-templates 渲染空态 / ❌ 文件不存在)
- **对比基线**`e:\desktop\CICD\src\app\(dashboard)\teacher\`CICD 原版 53 页)
- **规划依据**`apps/portal-shell/ARCHITECTURE.md` §9.1(教师域 56 页规划)
---
**分析完成。下一步建议按 §五 优先级逐项补做,每补一页同步更新 ARCH §9.1 状态 + 004 + arch.db。**
## 七、附录
### 7.1 教师域 63 个 page.tsx 完整清单
| # | 路径 | 状态 | 集成的 client |
| --- | ----------------------------------------------------------------- | ---- | ----------------------------------------------------------- |
| 1 | `/shell/teacher` | ✅ | dashboard-cards-client |
| 2 | `/shell/teacher/ai/ai-assist` | ✅ | ai-assist-client |
| 3 | `/shell/teacher/ai/ai-lesson-plan` | ✅ | ai-lesson-plan-client |
| 4 | `/shell/teacher/ai/ai-report` | ✅ | ai-report-client |
| 5 | `/shell/teacher/analytics` | ✅ | analytics-client |
| 6 | `/shell/teacher/analytics/[studentId]` | ✅ | student-analytics-client |
| 7 | `/shell/teacher/attendance` | ✅ | attendance-client |
| 8 | `/shell/teacher/attendance/report` | ✅ | attendance-report-client |
| 9 | `/shell/teacher/attendance/sheet` | ✅ | attendance-sheet-client |
| 10 | `/shell/teacher/attendance/stats` | ✅ | attendance-stats-client |
| 11 | `/shell/teacher/classes` | ✅ | classes-list-client |
| 12 | `/shell/teacher/classes/my` | ✅ | classes-list-client别名 |
| 13 | `/shell/teacher/classes/[id]` | ✅ | class-detail-client |
| 14 | `/shell/teacher/classes/my/[id]` | ✅ | class-detail-client别名 |
| 15 | `/shell/teacher/classes/schedule` | ✅ | class-schedule-client |
| 16 | `/shell/teacher/classes/students` | 🟡 | (占位,建议重定向到 /students |
| 17 | `/shell/teacher/course-plans` | ✅ | course-plans-list-client |
| 18 | `/shell/teacher/course-plans/[id]` | ✅ | course-plan-detail-client |
| 19 | `/shell/teacher/diagnostic` | ✅ | diagnostic-client |
| 20 | `/shell/teacher/diagnostic/class/[classId]` | ✅ | class-diagnostic-client |
| 21 | `/shell/teacher/diagnostic/student/[studentId]` | ✅ | student-diagnostic-client |
| 22 | `/shell/teacher/elective` | ✅ | elective-list-client |
| 23 | `/shell/teacher/elective/create` | ✅ | elective-create-client |
| 24 | `/shell/teacher/elective/[id]/edit` | ✅ | elective-edit-client |
| 25 | `/shell/teacher/error-book` | ✅ | error-book-client |
| 26 | `/shell/teacher/exams` | ✅ | exams-list-client |
| 27 | `/shell/teacher/exams/all` | ✅ | exams-list-client别名 |
| 28 | `/shell/teacher/exams/new` | ✅ | new-exam-client |
| 29 | `/shell/teacher/exams/create` | ✅ | new-exam-client别名 |
| 30 | `/shell/teacher/exams/[id]` | ✅ | exam-detail-client |
| 31 | `/shell/teacher/exams/[id]/analytics` | ✅ | exam-analytics-client |
| 32 | `/shell/teacher/exams/[id]/build` | ✅ | exam-build-client |
| 33 | `/shell/teacher/exams/[id]/edit` | ✅ | exam-edit-client |
| 34 | `/shell/teacher/exams/[id]/proctoring` | ✅ | proctoring-client |
| 35 | `/shell/teacher/exams/grading` | 🟡 | (占位,建议重定向到 /homework/submissions |
| 36 | `/shell/teacher/exams/grading/[submissionId]` | 🟡 | (占位,建议重定向到 /homework/submissions/[submissionId] |
| 37 | `/shell/teacher/grades` | ✅ | grades-client |
| 38 | `/shell/teacher/grades/analytics` | ✅ | grade-analytics-client |
| 39 | `/shell/teacher/grades/entry` | ✅ | grade-entry-client |
| 40 | `/shell/teacher/grades/report-card` | ✅ | report-card-client |
| 41 | `/shell/teacher/grades/stats` | ✅ | grade-stats-client |
| 42 | `/shell/teacher/homework` | ✅ | homework-list-client |
| 43 | `/shell/teacher/homework/new` | ✅ | new-homework-client |
| 44 | `/shell/teacher/homework/assignments/create` | ✅ | new-homework-client别名 |
| 45 | `/shell/teacher/homework/[id]` | ✅ | homework-detail-client |
| 46 | `/shell/teacher/homework/assignments/[id]/submissions` | ✅ | submissions-list-client |
| 47 | `/shell/teacher/homework/submissions` | ✅ | submissions-list-client |
| 48 | `/shell/teacher/homework/submissions/[submissionId]` | ✅ | submission-grading-client |
| 49 | `/shell/teacher/homework/submissions/[submissionId]/scan-grading` | ✅ | scan-grading-client |
| 50 | `/shell/teacher/knowledge-graph` | ✅ | knowledge-graph-client |
| 51 | `/shell/teacher/leave` | ✅ | leave-client |
| 52 | `/shell/teacher/lesson-plans` | ✅ | lesson-plans-list-client |
| 53 | `/shell/teacher/lesson-plans/new` | ✅ | new-lesson-plan-client |
| 54 | `/shell/teacher/lesson-plans/calendar` | ✅ | lesson-plan-calendar-client |
| 55 | `/shell/teacher/lesson-plans/heatmap` | ✅ | lesson-plan-heatmap-client |
| 56 | `/shell/teacher/lesson-plans/library` | ✅ | lesson-plan-library-client |
| 57 | `/shell/teacher/lesson-plans/[planId]/edit` | ✅ | lesson-plan-edit-client |
| 58 | `/shell/teacher/practice` | ✅ | practice-client |
| 59 | `/shell/teacher/questions` | ✅ | questions-client |
| 60 | `/shell/teacher/schedule-changes` | ✅ | schedule-changes-client |
| 61 | `/shell/teacher/students` | ✅ | students-list-client |
| 62 | `/shell/teacher/textbooks` | ✅ | textbooks-list-client |
| 63 | `/shell/teacher/textbooks/[id]` | ✅ | textbook-detail-client |
### 7.2 features/teacher 模块与 client 组件清单20 模块 / 55 client
| 模块目录 | client 组件数 | client 组件清单 |
| ---------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| ai | 3 | ai-assist / ai-lesson-plan / ai-report |
| analytics | 2 | analytics / student-analytics |
| attendance | 4 | attendance / attendance-report / attendance-sheet / attendance-stats |
| classes | 3 | classes-list / class-detail / class-schedule |
| course-plans | 2 | course-plans-list / course-plan-detail |
| dashboard | 1 | dashboard-cards |
| diagnostic | 3 | diagnostic / class-diagnostic / student-diagnostic |
| elective | 3 | elective-list / elective-create / elective-edit |
| error-book | 1 | error-book |
| exams | 7 | exams-list / new-exam / exam-detail / exam-analytics / exam-build / exam-edit / proctoring |
| grades | 5 | grades / grade-analytics / grade-entry / report-card / grade-stats |
| homework | 8 | homework-list / new-homework / homework-detail / submissions-list / submission-grading / scan-grading |
| knowledge-graph | 1 | knowledge-graph |
| leave | 1 | leave |
| lesson-plans | 6 | lesson-plans-list / new-lesson-plan / lesson-plan-calendar / lesson-plan-heatmap / lesson-plan-library / lesson-plan-edit |
| practice | 1 | practice |
| proctoring | 1 | proctoring共享给 exams |
| questions | 1 | questions |
| schedule-changes | 1 | schedule-changes |
| students | 1 | students-list |
| textbooks | 2 | textbooks-list / textbook-detail |
> **注**proctoring-client 位于 `features/teacher/proctoring/`,被 `exams/[id]/proctoring/page.tsx` 集成scan-grading-client 位于 `features/teacher/homework/`,被 `homework/submissions/[submissionId]/scan-grading/page.tsx` 集成。
---
## 八、变更历史
| 日期 | 版本 | 变更内容 |
| ---------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-24 | v1 | 初版核查53→63 页迁移分析) |
| 2026-08-04 | v2 | 严格三态核查(逐页读 page.tsx60 ✅ / 3 🟡 / 0 ❌3 占位页改为重定向建议15 功能差距页归 P1-P2 backlog补全附录 §七.1 63 页完整清单 + §七.2 55 client 清单 |

View File

@@ -0,0 +1,678 @@
# 教师域功能迁移差异分析报告
> 对比基准CICD 项目(`E:\Desktop\CICD` → portal-shell 项目(`apps/portal-shell`
> 生成日期2026-07-27
> 用途:后续迁移工作的依据清单
## 对比范围说明
| 维度 | CICD | portal-shell |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| 教师页面入口 | `src/app/(dashboard)/teacher/` | `src/app/shell/teacher/` |
| 教师域模块 | `src/modules/{exams,grades,homework,lesson-preparation,attendance,classes,diagnostic,elective,error-book,course-plans,leave-requests,questions,textbooks,ai,proctoring,adaptive-practice,dashboard}/` | `src/features/teacher/{对应模块}/` |
| 数据层 | Server Actions + data-access | `src/lib/api/` GraphQL hooks + MSW 兜底 |
| 路由结构 | `(dashboard)` 路由组 | `shell` 路由组 |
> **架构差异说明**portal-shell 采用 GraphQL hooks`useXxx`)替代 CICD 的 Server Actions`actions*.ts`+ data-access 层(`data-access*.ts`)。因此 CICD 中的 `actions*.ts` / `data-access*.ts` / `schema.ts` / `types.ts` 等非组件文件在 portal-shell 中**按设计未迁移**,对应能力由 `lib/api/*.ts` + `features/*/transformations.ts` 承担。本报告对这类"按架构设计未迁移"的文件不予标记为缺失,仅关注**组件/UI 能力**层面的缺失与简化。
---
## 第一部分缺失页面清单CICD 有但 portal-shell 没有的路由)
### 1.1 真正缺失的路由
| CICD 路由 | 对应文件 | portal-shell 状态 | 备注 |
| -------------------- | ---------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/teacher/dashboard` | `teacher/dashboard/page.tsx` | ⚠️ 功能合并 | portal-shell 将仪表盘合并进 `/teacher/page.tsx``TeacherDashboardPage`功能保留但路由路径不同。CICD 的 `/teacher/page.tsx`773B是入口跳转`/teacher/dashboard/page.tsx`835B是实际仪表盘 |
**结论**portal-shell 无完全缺失的教师路由。`/teacher/dashboard` 被合并到 `/teacher` 根路由,属结构性调整而非缺失。
### 1.2 路由名称变更(非缺失,但路径不同)
| CICD 路由 | portal-shell 路由 | 说明 |
| ------------------------------- | -------------------------- | ---------------------------------------------------------- |
| `/teacher/exams/[id]/edit-rich` | `/teacher/exams/[id]/edit` | 重命名;实现由 Tiptap 简化为 contentEditable见第二部分 |
### 1.3 portal-shell 新增的路由CICD 没有)
| portal-shell 路由 | 说明 |
| ------------------------------------------------------ | ---------------------------------------------------------- |
| `/teacher/ai/ai-assist` | AI 助教页CICD 中 ai 模块以 widget 形式嵌入,无独立路由) |
| `/teacher/ai/ai-lesson-plan` | AI 教案页 |
| `/teacher/ai/ai-report` | AI 报告页 |
| `/teacher/analytics``/teacher/analytics/[studentId]` | 学情分析页CICD 中归属 grades/analytics |
| `/teacher/knowledge-graph` | 知识图谱页CICD 中归属 textbooks 模块的 graph 组件) |
| `/teacher/students` | 学生列表页 |
| `/teacher/homework/[id]` | 作业详情CICD 用 `assignments/[id]` |
| `/teacher/homework/new` | 新建作业CICD 用 `assignments/create` |
| `/teacher/classes/[id]` | 班级详情顶层路由CICD 仅 `my/[id]` |
---
## 第二部分简化迁移页面清单portal-shell 实现是简化版,需补全功能)
| 页面路由 | CICD 实现 | portal-shell 实现 | 简化点 | 补全建议 |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `/teacher/exams/[id]/edit`(原 edit-rich | Tiptap 富文本编辑器(`exam-rich-editor.tsx` 懒加载 `exam-rich-editor-inner.tsx`,含 7 个扩展节点blank-node / dotted-mark / group-block / image-node / question-block / section-block`selection-toolbar.tsx`;双向转换 3 文件) | `exam-rich-form.tsx` 使用 `contentEditable` + `document.execCommand`(见文件头注释:"移除 ExamRichEditor 依赖,使用 contentEditable 简化实现" | 缺失整个 Tiptap 编辑器基础设施、自定义节点扩展、结构与编辑器双向转换、选区工具栏 | 迁移 `editor/` 目录全部文件 + `@tiptap/*` 依赖 |
| `/teacher/lesson-plans/[planId]/edit` | `paper-editor.tsx` 基于 Tiptap依赖 `useLessonPlanEditor` hook+ `textbook-tiptap-editor.tsx` | `paper-editor.tsx` 使用 `contentEditable` + `document.execCommand`(见文件头注释:"使用 contentEditable 替代 Tiptapprop-driven" | 富文本能力退化,无法支持复杂块级结构 | 引入 Tiptap迁移 `textbook-tiptap-editor.tsx` |
| `/teacher/exams/[id]/proctoring` | 独立 `proctoring` 模块(`anti-cheat-monitor.tsx` / `exam-mode-config.tsx` / `proctoring-dashboard.tsx`+ WebSocket 实时推送 | `proctoring-client.tsx` 使用 5s 轮询模拟实时刷新(见文件头注释:"WS 契约未就绪...通过内置 5s 轮询模拟实时刷新" | 缺反作弊监控、考试模式配置、WS 实时推送 | 待 WS 契约就绪后改接实时推送;补 `anti-cheat-monitor` / `exam-mode-config` 组件 |
| `/teacher/exams/[id]/build` | 组卷工作台 + `exam-assembly.tsx` 完整子组件 | `exam-build-client.tsx` 三栏布局存在,但 `@contract-pending` 全 MSW 兜底 | 契约未接通,数据为 mock | 接通 `examBuild` / `questionsLibrary` / `saveExamBuild` 真实契约 |
| `/teacher/homework/submissions/[submissionId]/scan-grading` | `homework-scan-grading-view.tsx` | `scan-grading-client.tsx`(重命名),`@contract-pending` MSW 兜底 | 契约未接通 | 接通 `submissionDetail` / `saveScanGrading` 真实契约 |
| `/teacher/grades/entry` | `batch-grade-entry.tsx` + 独立 `hooks/use-batch-grade-entry-undo.ts` + `hooks/use-draft-lock.ts` | `batch-grade-entry.tsx` 存在但 undo hook **内联为本地实现** | 撤销逻辑内联,未独立 Hook 化 | 当前功能完整,若需复用可抽出 hook |
| `/teacher/attendance/*`sheet/stats/report | 独立 10+ 子组件(`attendance-sheet.tsx` / `attendance-trend-chart.tsx` / `attendance-report-print.tsx` / `attendance-warnings-card.tsx` / `attendance-grade-correlation-card.tsx` / `class-comparison-card.tsx` / `student-attendance-view.tsx` / `attendance-rules-form.tsx` / `attendance-stats-class-selector.tsx` / `attendance-record-list.tsx` / `attendance-page-layout.tsx` | 4 个 client 文件,子组件内联或缺失 | 缺趋势图、成绩相关性卡、预警卡、班级对比卡、打印、规则表单、记录列表等 | 至少补 `attendance-trend-chart` / `attendance-report-print` / `attendance-warnings-card` |
| `/teacher/error-book` | 13 个子组件(含 5 个图表 + 2 个对话框 + `grouped-student-error-table` / `subject-tabs` / `class-filter` / `review-buttons` / `error-book-item-card` / `error-book-list` | 仅 `error-book-list-client.tsx` 1 个 client无 components 子目录 | 图表与对话框全缺,列表能力退化 | 补齐图表组件与详情对话框 |
| `/teacher/textbooks/[id]` | 22 个组件(含知识图谱 9 个 + 教材阅读器 3 个 + 章节管理 2 个 + 知识点 2 个 + 表单 6 个)+ 8 个 hooks + `graph-layout.ts` | 仅 `textbook-detail-client.tsx` + `textbooks-list-client.tsx` + `transformations.ts`;图谱能力拆到独立 `knowledge-graph` feature仅 1 个 client | 阅读器、章节管理、知识点 CRUD、图谱交互层全缺 | 补齐教材阅读器与章节管理 |
| `/teacher/questions` | 12 个组件(`create-question-dialog` / `create-question-button` / `knowledge-point-selector` / `options-editor` / `question-bank-results-client` / `question-columns` / `question-data-table` 等) | 6 个组件(`batch-operations` / `import-export-buttons` / `question-actions` / `question-cascade-filter` / `question-content-renderer` / `index` | 缺题目创建对话框、知识点选择器、选项编辑器、题目数据表 | 补齐 `create-question-dialog` / `knowledge-point-selector` / `options-editor` / `question-data-table` |
| `/teacher/ai/*`3 页面) | 15+ 组件(`ai-assistant-widget` / `ai-chat-panel` / `ai-chat-input` / `ai-chat-messages` / `ai-chart-renderer` / `ai-markdown-renderer` / `ai-provider-selector` / `ai-usage-dashboard` / `ai-lesson-content-generator` / `ai-grading-assist` / `ai-error-book-analysis` / `ai-question-variant-generator` 等)+ context + 5 hooks + services | 仅 3 个 client`ai-assist-client.tsx` / `ai-lesson-plan-client.tsx` / `ai-report-client.tsx`+ `transformations.ts` | AI 聊天面板、图表渲染、Markdown 渲染、Provider 选择器、用量看板等全缺 | 至少补 `ai-chat-panel` / `ai-markdown-renderer` / `ai-provider-selector` / `ai-usage-dashboard` |
---
## 第三部分:每个模块的缺失组件清单
> 标注规则:✅ 已迁移;❌ 缺失;⚠️ 存在但简化/重命名;➖ 按架构设计未迁移Server Actions / data-access / schema 等非组件文件portal-shell 用 GraphQL hooks 替代)
### 3.1 exams 模块
#### 组件components/
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------------------- | ----------------- | --------------------------- |
| `assembly/exam-paper-preview.tsx` | ❌ 缺失 | assembly/ 下未迁移此文件 |
| `assembly/question-bank-list.tsx` | ✅ 已迁移 | |
| `assembly/selected-question-list.tsx` | ✅ 已迁移 | |
| `assembly/structure-editor.tsx` | ✅ 已迁移 | |
| `exam-actions.tsx` | ✅ 已迁移 | |
| `exam-ai-generator.tsx` | ✅ 已迁移 | AI 出题 |
| `exam-analytics-dashboard.tsx` | ✅ 已迁移 | |
| `exam-assembly-config.tsx` | ✅ 已迁移 | |
| `exam-assembly-question-pool.tsx` | ✅ 已迁移 | |
| `exam-assembly-selected.tsx` | ✅ 已迁移 | |
| `exam-assembly.tsx` | ✅ 已迁移 | 试卷组装 |
| `exam-basic-info-form.tsx` | ✅ 已迁移 | |
| `exam-boundaries.tsx` | ❌ 缺失 | 分数边界组件 |
| `exam-card.tsx` | ✅ 已迁移 | |
| `exam-columns.tsx` | ❌ 缺失 | 表格列定义 |
| `exam-data-table.tsx` | ✅ 已迁移 | |
| `exam-form-types.ts` | ✅ 已迁移 | |
| `exam-form.tsx` | ✅ 已迁移 | |
| `exam-grid.tsx` | ✅ 已迁移 | |
| `exam-mode-selector.tsx` | ✅ 已迁移 | |
| `exam-preview-dialog.tsx` | ✅ 已迁移 | |
| `exam-preview-question-editor.tsx` | ✅ 已迁移 | |
| `exam-preview-utils.ts` | ✅ 已迁移 | |
| `exam-preview.tsx` | ✅ 已迁移 | |
| `exam-rich-form.tsx` | ⚠️ 简化 | contentEditable 替代 Tiptap |
| `exam-viewer.tsx` | ✅ 已迁移 | |
| `question-options-editor.tsx` | ✅ 已迁移 | |
| `question-sub-questions-editor.tsx` | ✅ 已迁移 | |
#### Tiptap 编辑器editor/)—— 整目录缺失17 文件)
`exam-rich-editor.tsx`(懒加载入口)/ `exam-rich-editor-inner.tsx`Tiptap 实现)/ `exam-rich-editor-types.ts` / `editor-to-structure.ts` / `structure-to-editor.ts` / `exam-nodes-to-editor-doc.ts` / `selection-toolbar.tsx` / `types.ts` / `index.ts` / `extensions/`blank-node / dotted-mark / group-block / image-node / index / question-block / section-block 共 7 文件)/ `utils/count-questions.ts` —— **全部 ❌ 缺失**
#### Hookshooks/)—— 整目录缺失4 文件)
`use-exam-preview.ts` / `use-exam-preview-rewrite.ts` / `use-exam-preview-state.ts` / `use-exam-preview-tasks.ts` —— **全部 ❌ 缺失**
#### AI Pipelineai-pipeline/)—— 整目录缺失5 文件)
`auto-mark.ts` / `index.ts` / `parse.ts` / `request.ts` / `structure.ts` —— **全部 ❌ 缺失**
#### Services / Config / Utils
`services/exam-service-context.tsx` / `services/exam-service-port.ts` / `services/index.ts` / `config/exam-widgets.ts` / `utils/exam-structure-tree.ts` / `utils/normalize-structure.ts` / `lib/type-guards.ts` —— **全部 ❌ 缺失**
#### 非组件文件(按架构设计未迁移)
`actions.ts` / `actions-helpers.ts` / `actions-rich-editor.ts` / `data-access.ts` / `data-access-cross-module.ts` / `data-access-error-collection.ts` / `stats-service.ts` / `types.ts` `lib/api/exams.ts` + `transformations.ts` 替代
### 3.2 grades 模块
#### 组件components/)—— 全部已迁移30+ 文件)
`batch-grade-entry.tsx` + 5 子组件dialog/selectors/stats/table/toolbar/ `class-comparison-chart.tsx` / `class-grade-report.tsx` / `draft-lock-banner.tsx` / `excel-import-dialog.tsx` / `export-button.tsx` / `grade-distribution-chart.tsx` / `grade-record-detail.tsx` / `grade-record-form.tsx` / `grade-record-list.tsx` / `grade-record-row.tsx` / `grade-trend-card.tsx` / `grade-trend-chart.tsx` / `growth-archive-chart.tsx` / `knowledge-point-mastery-chart.tsx` / `ranking-trend-card.tsx` / `report-card-print-action.tsx` / `report-card-print-button.tsx` / `report-card-view.tsx` / `school-wide-summary-card.tsx` / `score-cell.tsx` / `stats-class-selector.tsx` / `student-grade-summary.tsx` / `subject-comparison-chart.tsx` —— **全部 ✅ 已迁移**
#### Hooks / Lib
| CICD 文件 | portal-shell 状态 |
| -------------------------------------------- | ---------------------------------------- |
| `hooks/use-batch-grade-entry-undo.ts` | ⚠️ 内联到 `batch-grade-entry.tsx` |
| `hooks/use-draft-lock.ts` | ⚠️ 内联 |
| `lib/grade-utils.ts` | ❌ 缺失 |
| `lib/notify.ts` | ❌ 缺失(用 `@/shared/lib/notify` 替代) |
| `lib/report-card.ts` | ❌ 缺失 |
| `lib/scope-check.ts` / `lib/scope-filter.ts` | ❌ 缺失(权限范围校验) |
#### 非组件文件(按架构设计未迁移)
`actions*.ts`6 个)/ `data-access*.ts`10 个)/ `export.ts` / `import-export.ts` / `stats-service.ts` / `schema.ts` / `types.ts` `lib/api/grades.ts` 替代
### 3.3 homework 模块
#### 组件components/
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------------------------------------ | ----------------- | ------------------------------ |
| `excellent-submissions.tsx` | ❌ 缺失 | 优秀提交展示 |
| `homework-assignment-exam-content-card.tsx` | ❌ 缺失 | 作业关联试卷内容卡 |
| `homework-assignment-exam-error-explorer.tsx` | ❌ 缺失 | 作业试卷错误探索 |
| `homework-assignment-exam-error-explorer-lazy.tsx` | ❌ 缺失 | 懒加载版本 |
| `homework-assignment-exam-preview-pane.tsx` | ❌ 缺失 | 作业试卷预览面板 |
| `homework-assignment-form.tsx` | ❌ 缺失 | 作业表单 |
| `homework-assignment-question-error-detail-panel.tsx` | ❌ 缺失 | 题目错误详情面板 |
| `homework-assignment-question-error-overview-card.tsx` | ❌ 缺失 | 题目错误概览卡 |
| `homework-batch-grading-view.tsx` | ✅ 已迁移 | 批量批改 |
| `homework-grading-panel.tsx` | ✅ 已迁移 | |
| `homework-grading-toolbar.tsx` | ✅ 已迁移 | |
| `homework-grading-view.tsx` | ✅ 已迁移 | |
| `homework-scan-grading-view.tsx` | ⚠️ 重命名 | 改为 `scan-grading-client.tsx` |
| `homework-submission-result.tsx` | ✅ 已迁移 | |
| `homework-take-confirm-dialog.tsx` | ✅ 已迁移 | |
| `homework-take-question.tsx` | ✅ 已迁移 | |
| `homework-take-sidebar.tsx` | ✅ 已迁移 | |
| `homework-take-toolbar.tsx` | ✅ 已迁移 | |
| `homework-take-view.tsx` | ✅ 已迁移 | |
| `question-renderer.tsx` | ✅ 已迁移 | |
| `scan-image-viewer.tsx` | ✅ 已迁移 | |
| `scan-uploader.tsx` | ✅ 已迁移 | |
| `student-homework-review-view.tsx` | ❌ 缺失 | 学生作业复习视图 |
#### 非组件文件(按架构设计未迁移)
`actions.ts` / `data-access*.ts`7 个)/ `stats-service.ts` / `schema.ts` / `types.ts` `lib/api/homework.ts` 替代
### 3.4 lesson-plans 模块CICD: lesson-preparation
#### 组件 —— 大部分已迁移,编辑器简化
| CICD 文件 | portal-shell 状态 | 备注 |
| ---------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `paper-editor/paper-editor.tsx` | ⚠️ 简化 | contentEditable 替代 Tiptap |
| `paper-editor/textbook-tiptap-editor.tsx` | ❌ 缺失 | Tiptap 编辑器 |
| `paper-editor/paper-toolbar.tsx` | ✅ 已迁移 | |
| `paper-editor/paper-context-menu.tsx` | ✅ 已迁移 | |
| `paper-editor/inline-node.tsx` | ✅ 已迁移 | |
| `paper-editor/inline-qa-dialog.tsx` | ✅ 已迁移 | |
| `blocks/` 目录14 个块组件) | ✅ 已迁移 | objective/exercise/homework/import/interaction/key-point/new-teaching/reflection/rich-text/summary/text-study/blackboard + types + type-guards |
| `detail-panel/` 目录5 个文件) | ✅ 已迁移 | detail-head/panel/props/qa-editor |
| `structure-tree/` 目录2 个文件) | ✅ 已迁移 | structure-tree/tree-node-row |
| `ai-feedback-dialog.tsx` | ✅ 已迁移 | AI 反馈 |
| `ai-differentiation-dialog.tsx` | ✅ 已迁移 | |
| `anchor-migration-banner.tsx` | ✅ 已迁移 | |
| `attachment-picker.tsx` | ✅ 已迁移 | |
| `consistency-check-dialog.tsx` | ✅ 已迁移 | |
| `inline-question-editor.tsx` | ✅ 已迁移 | |
| `knowledge-point-picker.tsx` | ✅ 已迁移 | |
| `lesson-plan-calendar-client.tsx` | ✅ 已迁移 | 日历视图 |
| `lesson-plan-card.tsx` | ✅ 已迁移 | |
| `lesson-plan-dialogs.tsx` | ✅ 已迁移 | |
| `lesson-plan-edit-client.tsx` | ✅ 已迁移 | |
| `lesson-plan-heatmap-client.tsx` | ✅ 已迁移 | 热力图 |
| `lesson-plan-library-client.tsx` | ✅ 已迁移 | |
| `lesson-plan-list-client.tsx` | ✅ 已迁移 | |
| `lesson-plan-mobile-view.tsx` | ✅ 已迁移 | |
| `lesson-plan-outline-tree.tsx` | ✅ 已迁移 | |
| `lesson-plan-publish-button.tsx` | ✅ 已迁移 | |
| `lesson-plan-readonly-view.tsx` | ✅ 已迁移 | |
| `lesson-plan-toolbar.tsx` | ✅ 已迁移 | |
| `new-lesson-plan-client.tsx` | ✅ 已迁移 | |
| `version-diff-view.tsx` / `version-diff-viewer.tsx` / `version-history-drawer.tsx` | ✅ 已迁移 | 版本对比 |
#### 非组件文件(按架构设计未迁移)
`actions*.ts`12 个)/ `data-access*.ts`13 个)/ `publish-service.ts` / `ai-suggest.ts` / `seed-templates.ts` / `constants.ts` / `schema.ts` / `types.ts` `lib/api/lesson-plans.ts` 替代
### 3.5 attendance 模块
#### 组件components/)—— 大量缺失
| CICD 文件 | portal-shell 状态 | 备注 |
| --------------------------------------- | ----------------- | ------------------ |
| `attendance-grade-correlation-card.tsx` | ❌ 缺失 | 考勤成绩相关性卡 |
| `attendance-page-layout.tsx` | ❌ 缺失 | 页面布局 |
| `attendance-record-list.tsx` | ❌ 缺失 | 记录列表 |
| `attendance-report-print.tsx` | ❌ 缺失 | 报告打印 |
| `attendance-rules-form.tsx` | ❌ 缺失 | 规则表单 |
| `attendance-sheet.tsx` | ❌ 缺失 | 考勤表(独立组件) |
| `attendance-stats-class-selector.tsx` | ❌ 缺失 | 统计班级选择器 |
| `attendance-trend-chart.tsx` | ❌ 缺失 | 趋势图 |
| `attendance-warnings-card.tsx` | ❌ 缺失 | 预警卡 |
| `class-comparison-card.tsx` | ❌ 缺失 | 班级对比卡 |
| `student-attendance-view.tsx` | ❌ 缺失 | 学生考勤视图 |
#### portal-shell 已有
`attendance-list-client.tsx` / `attendance-sheet-client.tsx` / `attendance-stats-client.tsx` / `attendance-report-client.tsx` / `transformations.ts`4 个 client 整合了部分能力,但子组件未拆分)
#### 非组件文件(按架构设计未迁移)
`actions.ts` / `data-access*.ts`3 个)/ `correlation-compute.ts` / `trend-compute.ts` / `warning-compute.ts` / `export.ts` / `notifications.ts` / `constants.ts` / `schema.ts` / `types.ts` 部分计算逻辑(`trend-compute` / `warning-compute` / `correlation-compute`)应迁入 `transformations.ts`
### 3.6 classes 模块
#### 组件 —— 全部已迁移
| CICD 文件 | portal-shell 状态 |
| ---------------------------------------------------------------------------------------- | ----------------------------------- |
| `class-detail/` 目录7 个 widget + edit-class-dialog | ✅ 已迁移 |
| `class-delete-dialog.tsx` | ✅ 已迁移 |
| `class-form-dialog.tsx` | ❌ 缺失(合并入 edit-class-dialog |
| `class-form-utils.ts` | ❌ 缺失 |
| `class-invitation-manager.tsx` | ✅ 已迁移 |
| `class-list-table.tsx` | ✅ 已迁移 |
| `class-list-toolbar.tsx` | ✅ 已迁移 |
| `admin-classes-view.tsx` | 管理员视图(非教师域) |
| `grade-classes-view.tsx` | 年级视图(非教师域) |
| `my-classes-grid.tsx` | ✅ 已迁移 |
| `schedule-create-dialog.tsx` / `schedule-delete-dialog.tsx` / `schedule-edit-dialog.tsx` | ✅ 已迁移 |
| `schedule-utils.ts` | ✅ 已迁移 |
| `schedule-view.tsx` | ✅ 已迁移 |
| `students-table.tsx` | ✅ 已迁移 |
#### Hooks / Lib
| CICD 文件 | portal-shell 状态 |
| ---------------------------- | ------------------------- |
| `hooks/use-class-data.ts` | ❌ 缺失 |
| `hooks/use-class-filters.ts` | ❌ 缺失 |
| `lib/admin-class-mappers.ts` | 管理员映射(非教师域) |
### 3.7 diagnostic 模块
#### 组件 / Services
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------------------------ | ----------------- | ---------------------------------------------------- |
| `components/class-diagnostic-view.tsx` | ⚠️ 整合 | portal-shell 用 `diagnostic-class-detail-client.tsx` |
| `components/student-diagnostic-view.tsx` | ⚠️ 整合 | portal-shell 用 `student-diagnostic-client.tsx` |
| `components/confidence-utils.ts` | ❌ 缺失 | 置信度工具 |
| `components/mastery-radar-chart.tsx` | ❌ 缺失 | 掌握度雷达图 |
| `components/report-list.tsx` | ❌ 缺失 | 报告列表 |
| `services/default-diagnostic-service.ts` | ❌ 缺失 | |
| `services/diagnostic-monitor-context.tsx` | ❌ 缺失 | |
| `services/diagnostic-monitor.ts` | ❌ 缺失 | |
| `services/diagnostic-service-context.tsx` | ❌ 缺失 | |
| `services/diagnostic-service.ts` | ❌ 缺失 | |
| `services/monitored-diagnostic-service.ts` | ❌ 缺失 | |
#### 非组件文件(按架构设计未迁移)
`actions.ts` / `data-access*.ts`2 个)/ `export.ts` / `role-config.ts` / `stats-service.ts` / `schema.ts` / `types.ts` `lib/api/diagnostic.ts` 替代
### 3.8 elective 模块
#### 组件
| CICD 文件 | portal-shell 状态 | 备注 |
| ---------------------------- | ----------------------- | ------------------------------------------------------------------------- |
| `elective-course-detail.tsx` | ❌ 缺失 | 课程详情 |
| `elective-course-form.tsx` | ⚠️ 整合 | portal-shell 用 `elective-create-client.tsx` / `elective-edit-client.tsx` |
| `elective-course-list.tsx` | ⚠️ 整合 | portal-shell 用 `elective-list-client.tsx` |
| `elective-page-layout.tsx` | ❌ 缺失 | 页面布局 |
| `parent-selection-view.tsx` | 家长视图(非教师域) |
| `student-selection-view.tsx` | 学生视图(非教师域) |
#### Lib
| CICD 文件 | portal-shell 状态 |
| -------------------------- | ----------------------- |
| `lib/lottery.ts` | ❌ 缺失(抽签算法) |
| `lib/schedule-conflict.ts` | ❌ 缺失(课表冲突检测) |
### 3.9 error-book 模块
#### 组件components/)—— 几乎全缺
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------------------ | ----------------- | ---------------------------------- |
| `add-error-book-dialog.tsx` | ❌ 缺失 | 添加错题对话框 |
| `chapter-weakness-chart.tsx` | ❌ 缺失 | 章节薄弱图 |
| `class-error-bar-chart.tsx` | ❌ 缺失 | 班级错题柱状图 |
| `class-filter.tsx` | ❌ 缺失 | 班级筛选 |
| `error-book-detail-dialog.tsx` | ❌ 缺失 | 错题详情对话框 |
| `error-book-item-card.tsx` | ❌ 缺失 | 错题卡片 |
| `error-book-list.tsx` | ❌ 缺失 | 列表portal-shell 内联到 client |
| `grouped-student-error-table.tsx` | ❌ 缺失 | 分组学生错题表 |
| `knowledge-point-weakness-chart.tsx` | ❌ 缺失 | 知识点薄弱图 |
| `review-buttons.tsx` | ❌ 缺失 | 复习按钮 |
| `subject-distribution-chart.tsx` | ❌ 缺失 | 学科分布图 |
| `subject-tabs.tsx` | ❌ 缺失 | 学科 Tab |
| `top-wrong-questions.tsx` | ❌ 缺失 | 高频错题 |
#### portal-shell 已有
`error-book-list-client.tsx` + `transformations.ts`
#### 非组件文件(按架构设计未迁移)
`actions.ts` / `data-access*.ts`3 个)/ `sm2-algorithm.ts`SM2 复习算法)/ `schema.ts` / `types.ts` `sm2-algorithm.ts` 是核心算法,建议迁入 `transformations.ts` 或独立 lib
### 3.10 course-plans 模块
#### 组件components/)—— 全缺
| CICD 文件 | portal-shell 状态 | 备注 |
| ----------------------------- | ----------------- | ----------------------------------------------- |
| `course-plan-calendar.tsx` | ❌ 缺失 | 日历组件 |
| `course-plan-detail.tsx` | ⚠️ 整合 | portal-shell 用 `course-plan-detail-client.tsx` |
| `course-plan-form.tsx` | ❌ 缺失 | 表单 |
| `course-plan-item-editor.tsx` | ❌ 缺失 | 条目编辑器 |
| `course-plan-list.tsx` | ⚠️ 整合 | portal-shell 用 `course-plan-list-client.tsx` |
| `course-plan-progress.tsx` | ❌ 缺失 | 进度组件 |
| `sortable-week-row.tsx` | ❌ 缺失 | 可排序周行 |
| `template-picker-dialog.tsx` | ❌ 缺失 | 模板选择对话框 |
#### Lib
| CICD 文件 | portal-shell 状态 |
| ----------------------- | ----------------- |
| `lib/calendar-utils.ts` | ❌ 缺失 |
| `lib/export-utils.ts` | ❌ 缺失 |
| `lib/track-event.ts` | ❌ 缺失 |
### 3.11 leave-requests 模块portal-shell: leave
#### 组件
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------- | ----------------- | --------------------------------------- |
| `leave-request-form.tsx` | ❌ 缺失 | 请假表单 |
| `leave-request-list.tsx` | ⚠️ 整合 | portal-shell 用 `leave-list-client.tsx` |
| `leave-review-dialog.tsx` | ❌ 缺失 | 审批对话框 |
| `leave-review-list.tsx` | ❌ 缺失 | 审批列表 |
### 3.12 questions 模块
#### 组件components/
| CICD 文件 | portal-shell 状态 | 备注 |
| ---------------------------------- | ----------------- | -------------- |
| `batch-operations.tsx` | ✅ 已迁移 | |
| `create-question-button.tsx` | ❌ 缺失 | 创建题目按钮 |
| `create-question-dialog.tsx` | ❌ 缺失 | 创建题目对话框 |
| `import-export-buttons.tsx` | ✅ 已迁移 | |
| `knowledge-point-selector.tsx` | ❌ 缺失 | 知识点选择器 |
| `options-editor.tsx` | ❌ 缺失 | 选项编辑器 |
| `question-actions.tsx` | ✅ 已迁移 | |
| `question-bank-results-client.tsx` | ❌ 缺失 | 题库结果客户端 |
| `question-cascade-filter.tsx` | ✅ 已迁移 | |
| `question-columns.tsx` | ❌ 缺失 | 表格列定义 |
| `question-content-renderer.tsx` | ✅ 已迁移 | |
| `question-data-table.tsx` | ❌ 缺失 | 题目数据表 |
#### Utils
| CICD 文件 | portal-shell 状态 |
| ------------------------ | ----------------- |
| `utils/parse-content.ts` | ❌ 缺失 |
| `utils/track-event.ts` | ❌ 缺失 |
### 3.13 textbooks 模块
#### 组件components/)—— 几乎全缺
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------------ | ----------------- | ---------------- |
| `chapter-sidebar-list.tsx` | ❌ 缺失 | 章节侧边栏 |
| `create-chapter-dialog.tsx` | ❌ 缺失 | 创建章节对话框 |
| `force-graph.tsx` | ❌ 缺失 | 力导向图 |
| `graph-kp-node.tsx` | ❌ 缺失 | 图谱知识点节点 |
| `graph-node-detail-panel.tsx` | ❌ 缺失 | 图谱节点详情面板 |
| `graph-prerequisite-edge.tsx` | ❌ 缺失 | 图谱前置依赖边 |
| `graph-toolbar.tsx` | ❌ 缺失 | 图谱工具栏 |
| `knowledge-graph-controls.tsx` | ❌ 缺失 | 图谱控制 |
| `knowledge-graph-inner.tsx` | ❌ 缺失 | 图谱内部 |
| `knowledge-graph-node.tsx` | ❌ 缺失 | 图谱节点 |
| `knowledge-graph.tsx` | ❌ 缺失 | 图谱主组件 |
| `knowledge-point-dialogs.tsx` | ❌ 缺失 | 知识点对话框 |
| `knowledge-point-list.tsx` | ❌ 缺失 | 知识点列表 |
| `textbook-card.tsx` | ❌ 缺失 | 教材卡片 |
| `textbook-content-panel.tsx` | ❌ 缺失 | 教材内容面板 |
| `textbook-form-dialog.tsx` | ❌ 缺失 | 教材表单对话框 |
| `textbook-form-fields.tsx` | ❌ 缺失 | 教材表单字段 |
| `textbook-reader-content.tsx` | ❌ 缺失 | 阅读器内容 |
| `textbook-reader-toc.tsx` | ❌ 缺失 | 阅读器目录 |
| `textbook-reader.tsx` | ❌ 缺失 | 阅读器主组件 |
| `textbook-settings-dialog.tsx` | ❌ 缺失 | 教材设置对话框 |
#### Hookshooks/)—— 全缺
`use-graph-data.ts` / `use-knowledge-point-actions.ts` / `use-kp-create.ts` / `use-kp-crud.ts` / `use-kp-delete.ts` / `use-kp-dialog-state.ts` / `use-kp-update.ts` / `use-text-selection.ts` —— **全部 ❌ 缺失**
#### Lib / Utils
| CICD 文件 | portal-shell 状态 |
| -------------------- | ----------------------- |
| `lib/type-guards.ts` | ❌ 缺失 |
| `graph-layout.ts` | ❌ 缺失(图谱布局算法) |
| `utils.ts` | ❌ 缺失 |
| `analytics.tsx` | ❌ 缺失 |
| `constants.ts` | ❌ 缺失 |
#### portal-shell 已有
`textbook-detail-client.tsx` + `textbooks-list-client.tsx` + `transformations.ts`;图谱能力拆到独立 `knowledge-graph` feature`knowledge-graph-client.tsx` + `transformations.ts`
### 3.14 ai 模块
#### 组件components/)—— 大量缺失
| CICD 文件 | portal-shell 状态 | 备注 |
| ------------------------------------------------------------------ | --------------------- | --------------- |
| `ai-assistant-widget.tsx` / `ai-assistant-widget-inner.tsx` | ❌ 缺失 | AI 助手浮窗 |
| `ai-chart-renderer.tsx` | ❌ 缺失 | 图表渲染 |
| `ai-chat-input.tsx` / `ai-chat-messages.tsx` / `ai-chat-panel.tsx` | ❌ 缺失 | 聊天面板三件套 |
| `ai-child-summary.tsx` | 家长域(非教师域) |
| `ai-error-book-analysis.tsx` | ❌ 缺失 | 错题分析 |
| `ai-grading-assist.tsx` | ❌ 缺失 | 批改助手 |
| `ai-lesson-content-generator.tsx` | ❌ 缺失 | 教案内容生成 |
| `ai-markdown-renderer.tsx` | ❌ 缺失 | Markdown 渲染 |
| `ai-provider-selector.tsx` | ❌ 缺失 | Provider 选择器 |
| `ai-question-variant-generator.tsx` | ❌ 缺失 | 题目变体生成 |
| `ai-study-path.tsx` | 学生域(非教师域) |
| `ai-usage-dashboard.tsx` | ❌ 缺失 | 用量看板 |
#### Context / Hooks / Services
| CICD 文件 | portal-shell 状态 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------- |
| `context/ai-client-provider.tsx` | ❌ 缺失 |
| `context/create-ai-client-service.ts` | ❌ 缺失 |
| `hooks/stream-utils.ts` / `use-ai-chat-stream.ts` / `use-ai-suggestion.ts` / `use-drag-position.ts` / `use-floating-ball.ts` / `use-position-persistence.ts` | ❌ 缺失 |
| `services/ai-service.ts` / `content-safety.ts` / `prompt-templates.ts` / `usage-tracker.ts` | ❌ 缺失 |
#### portal-shell 已有
`ai-assist-client.tsx` / `ai-lesson-plan-client.tsx` / `ai-report-client.tsx` / `transformations.ts`3 个 client 是页面级整合,但底层聊天/流式/Provider 能力全缺)
### 3.15 proctoring 模块CICD 独立模块portal-shell 并入 exams
| CICD 文件 | portal-shell 状态 | 备注 |
| -------------------------------------------- | ---------------------------------- | -------------------------------------------------- |
| `components/anti-cheat-monitor.tsx` | ❌ 缺失 | 反作弊监控 |
| `components/exam-mode-config.tsx` | ❌ 缺失 | 考试模式配置 |
| `components/proctoring-dashboard.tsx` | ⚠️ 整合 | portal-shell 用 `exams/proctoring-client.tsx` 替代 |
| `actions.ts` / `data-access.ts` / `types.ts` | `lib/api/proctoring.ts` 替代 |
### 3.16 adaptive-practice 模块CICD 有portal-shell 并入 practice
#### 组件 —— 全缺
`answer-input.tsx` / `answer-result.tsx` / `question-card.tsx` / `question-content.tsx`(答题核心)/ `class-knowledge-point-weakness-chart.tsx` / `class-practice-comparison-table.tsx` / `inactive-students-alert.tsx` / `practice-type-breakdown-chart.tsx` / `student-practice-ranking-table.tsx`(教师域分析图表)/ `practice-history.tsx` / `practice-result-view.tsx` / `practice-session-view.tsx` / `practice-starter.tsx` / `practice-starter-with-nav.tsx`(练习流程)/ `services/practice-service.tsx` / `lib/answer-utils.ts` / `lib/errors.ts` / `lib/grading.ts` / `lib/source-meta.ts` / `lib/type-guards.ts` —— **全部 ❌ 缺失**
#### portal-shell 已有
`practice/practice-list-client.tsx` + `transformations.ts`(仅列表页,练习流程与分析图表全缺)
### 3.17 dashboard 模块teacher-dashboard 子目录)
| CICD 文件 | portal-shell 状态 | 备注 |
| ---------------------------------------------- | ----------------- | --------------------------------------- |
| `teacher-dashboard/recent-submissions.tsx` | ❌ 缺失 | 最近提交 |
| `teacher-dashboard/teacher-classes-card.tsx` | ❌ 缺失 | 班级卡 |
| `teacher-dashboard/teacher-dashboard-view.tsx` | ⚠️ 整合 | portal-shell 内联到 `/teacher/page.tsx` |
| `teacher-dashboard/teacher-grade-trends.tsx` | ❌ 缺失 | 成绩趋势卡 |
| `teacher-dashboard/teacher-homework-card.tsx` | ❌ 缺失 | 作业卡 |
| `teacher-dashboard/teacher-quick-actions.tsx` | ❌ 缺失 | 快捷操作 |
| `teacher-dashboard/teacher-schedule.tsx` | ❌ 缺失 | 课表卡 |
| `teacher-dashboard/teacher-todo-card.tsx` | ❌ 缺失 | 待办卡 |
portal-shell 的 `/teacher/page.tsx``useTeacherDashboard` 接 data-ana 真实聚合查询,但仅展示 4 个 StatCard + 班级概览 + 预警,缺上述 7 个子组件卡片。
---
## 第四部分:核心功能完整性检查结果
### 4.1 exams 模块4 项)
| 核心功能 | 完整性 | 详细说明 |
| ------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Tiptap 富文本编辑器exam-rich-editor** | ❌ **不完整** | portal-shell `exam-rich-form.tsx``contentEditable` + `document.execCommand` 替代 Tiptap。CICD 的整个 `editor/` 目录17 个文件,含 7 个 Tiptap 扩展节点、双向转换、选区工具栏)未迁移。`/teacher/exams/[id]/edit` 路由对应 CICD 的 `edit-rich`,但实现退化。**影响**:无法支持试卷的题块/分节/分组/填空下划线/打点标记等结构化编辑 |
| **AI 出题exam-ai-generator** | ✅ **完整** | `exam-ai-generator.tsx` 已迁移,改为 prop-driven原版依赖 react-hook-form Control。AI Provider 选择、预览、后台任务列表能力保留 |
| **试卷组装exam-assembly** | ⚠️ **简化** | `exam-assembly.tsx` + `exam-assembly-config.tsx` + `exam-assembly-question-pool.tsx` + `exam-assembly-selected.tsx` + `assembly/` 3 子组件已迁移;但 `exam-build-client.tsx` 全 MSW 兜底(`@contract-pending``exam-boundaries.tsx` / `exam-columns.tsx` 缺失,`hooks/use-exam-preview*.ts`4 个)缺 |
| **监考proctoring** | ⚠️ **简化** | `proctoring-client.tsx` 存在(三栏布局:考试信息/学生状态/事件流),但用 5s 轮询模拟 WS 实时推送。CICD 独立 `proctoring` 模块的 `anti-cheat-monitor.tsx` / `exam-mode-config.tsx` 未迁移 |
### 4.2 grades 模块3 项)
| 核心功能 | 完整性 | 详细说明 |
| --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **批量录入batch-grade-entry** | ✅ **完整** | `batch-grade-entry.tsx` + 5 子组件dialog/selectors/stats/table/toolbar全部迁移。undo hook 内联为本地 token + `useUndoGradeEntry` 调用(功能保留) |
| **Excel 导入** | ✅ **完整** | `excel-import-dialog.tsx` 已迁移,用 `useDownloadGradeImportTemplate` + `useImportGradesFromExcel` hooks 替代 Server Actions。原生元素替代 shadcn Dialog/Select |
| **报表打印report-card-print** | ✅ **完整** | `report-card-print-action.tsx` + `report-card-print-button.tsx` + `report-card-view.tsx` + `report-card-client.tsx` 全部迁移 |
### 4.3 homework 模块2 项)
| 核心功能 | 完整性 | 详细说明 |
| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **扫描批改scan-grading** | ⚠️ **简化** | `scan-grading-client.tsx`(重命名自 `homework-scan-grading-view.tsx`)存在,三栏布局保留,但 `@contract-pending` MSW 兜底。`scan-image-viewer.tsx` + `scan-uploader.tsx` 已迁移 |
| **批量批改batch-grading** | ✅ **完整** | `homework-batch-grading-view.tsx` 已迁移,用 `useBatchAutoGrade` hook 替代 Server Action原生 table + input 替代 shadcn Table/Checkbox |
### 4.4 lesson-plans 模块5 项)
| 核心功能 | 完整性 | 详细说明 |
| ------------------------------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **教案编辑器lesson-plan-editor** | ❌ **不完整** | `paper-editor.tsx``contentEditable` 替代 Tiptap见文件头注释`textbook-tiptap-editor.tsx` 未迁移。blocks/ 目录 14 个块组件已迁移,但编辑器内核退化。**影响**:复杂块级结构编辑能力受限 |
| **AI 反馈ai-feedback** | ✅ **完整** | `ai-feedback-dialog.tsx` 已迁移,改为 prop-driven原版依赖 `useLessonPlanAi` hook`ai-differentiation-dialog.tsx` 也已迁移 |
| **版本对比version-diff** | ✅ **完整** | `version-diff-viewer.tsx` + `version-diff-view.tsx` + `version-history-drawer.tsx` 全部迁移 |
| **日历视图calendar** | ✅ **完整** | `lesson-plan-calendar-client.tsx` 已迁移 |
| **热力图heatmap** | ✅ **完整** | `lesson-plan-heatmap-client.tsx` 已迁移 |
### 4.5 attendance 模块3 项)
| 核心功能 | 完整性 | 详细说明 |
| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **考勤表sheet** | ⚠️ **简化** | `attendance-sheet-client.tsx` 存在,但 CICD 独立 `attendance-sheet.tsx` 组件未迁移(能力内联或缺失) |
| **统计stats** | ⚠️ **简化** | `attendance-stats-client.tsx` 存在,但 `attendance-trend-chart.tsx` / `attendance-stats-class-selector.tsx` / `class-comparison-card.tsx` / `attendance-grade-correlation-card.tsx` 未迁移 |
| **报告report** | ⚠️ **简化** | `attendance-report-client.tsx` 存在,但 `attendance-report-print.tsx`(打印)/ `attendance-warnings-card.tsx`(预警)未迁移 |
### 4.6 classes 模块3 项)
| 核心功能 | 完整性 | 详细说明 |
| ------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **班级详情** | ✅ **完整** | `class-detail-client.tsx` + `class-detail/` 7 个 widgetheader/overview-stats/quick-actions/schedule-widget/students-widget/assignments-widget/trends-widget全部迁移 |
| **学生管理** | ✅ **完整** | `students-table.tsx` 已迁移 |
| **课表** | ✅ **完整** | `class-schedule-client.tsx` + `schedule-view.tsx` + 3 个 schedule dialogcreate/edit/delete+ `schedule-utils.ts` 全部迁移 |
### 4.7 diagnostic 模块2 项)
| 核心功能 | 完整性 | 详细说明 |
| ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **班级诊断** | ⚠️ **简化** | `diagnostic-class-detail-client.tsx` 存在,但 CICD `class-diagnostic-view.tsx` / `mastery-radar-chart.tsx`(掌握度雷达图)/ `report-list.tsx` 未迁移 |
| **学生诊断** | ⚠️ **简化** | `student-diagnostic-client.tsx` + `student-diagnostic-transformations.ts` 存在,但 `student-diagnostic-view.tsx` 独立组件未迁移 |
### 4.8 ai 模块3 项)
| 核心功能 | 完整性 | 详细说明 |
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **AI 助教** | ⚠️ **简化** | `ai-assist-client.tsx` 存在(页面级),但 CICD `ai-assistant-widget` / `ai-chat-panel` / `ai-chat-input` / `ai-chat-messages` / `ai-markdown-renderer` / `ai-provider-selector` 等底层组件全缺,流式聊天能力缺失(`use-ai-chat-stream` / `stream-utils` 未迁移) |
| **AI 报告** | ⚠️ **简化** | `ai-report-client.tsx` 存在(页面级),但 `ai-chart-renderer.tsx` / `ai-usage-dashboard.tsx` 未迁移 |
| **AI 教案** | ⚠️ **简化** | `ai-lesson-plan-client.tsx` 存在(页面级),但 `ai-lesson-content-generator.tsx` 未迁移 |
### 4.9 核心功能完整性汇总
| 完整性 | 数量 | 功能列表 |
| --------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ✅ 完整 | 10 项 | exams AI 出题、grades 批量录入、grades Excel 导入、grades 报表打印、homework 批量批改、lesson-plans AI 反馈、lesson-plans 版本对比、lesson-plans 日历视图、lesson-plans 热力图、classes详情/学生/课表 3 项) |
| ⚠️ 简化 | 11 项 | exams 试卷组装、exams 监考、homework 扫描批改、attendance考勤表/统计/报告 3 项、diagnostic班级/学生 2 项、ai助教/报告/教案 3 项) |
| ❌ 不完整 | 2 项 | **exams Tiptap 富文本编辑器**、**lesson-plans 教案编辑器** |
---
## 第五部分:迁移优先级建议
### P0阻断核心教学流程建议优先补全
1. **exams Tiptap 富文本编辑器**:迁移 `editor/` 全目录17 文件)+ `@tiptap/*` 依赖,恢复试卷结构化编辑能力。涉及 7 个自定义节点扩展blank-node / dotted-mark / group-block / image-node / question-block / section-block+ 双向转换 + 选区工具栏
2. **lesson-plans Tiptap 编辑器**:迁移 `textbook-tiptap-editor.tsx`,恢复教案块级编辑能力。当前 `paper-editor.tsx` 用 contentEditable 退化blocks/ 14 个块组件已就绪但内核简化
3. **attendance 趋势图与预警卡**:补 `attendance-trend-chart.tsx` / `attendance-warnings-card.tsx` / `attendance-report-print.tsx`,恢复考勤统计可视化与打印
### P1影响功能完整性
4. **error-book 图表与对话框**:补 5 个图表组件(`chapter-weakness-chart` / `class-error-bar-chart` / `knowledge-point-weakness-chart` / `subject-distribution-chart` / `top-wrong-questions`+ 2 个对话框 + `sm2-algorithm.ts` 复习算法
5. **questions 创建与编辑**:补 `create-question-dialog.tsx` / `knowledge-point-selector.tsx` / `options-editor.tsx` / `question-data-table.tsx`,恢复题目 CRUD 完整流程
6. **textbooks 阅读器与章节管理**:补 `textbook-reader*.tsx`3 个)/ `chapter-sidebar-list.tsx` / `create-chapter-dialog.tsx`,恢复教材阅读与章节管理
7. **ai 聊天面板与流式**:补 `ai-chat-panel` 三件套input/messages/panel+ `ai-markdown-renderer` + `use-ai-chat-stream` + `ai-provider-selector`,恢复 AI 流式聊天能力
### P2增强体验
8. **proctoring 反作弊**:补 `anti-cheat-monitor.tsx` / `exam-mode-config.tsx`WS 契约就绪后改接实时推送(当前 5s 轮询)
9. **diagnostic 雷达图**:补 `mastery-radar-chart.tsx` / `confidence-utils.ts` / `report-list.tsx`
10. **dashboard 教师卡片**:补 7 个 teacher-dashboard 子组件卡片recent-submissions / teacher-classes-card / teacher-grade-trends / teacher-homework-card / teacher-quick-actions / teacher-schedule / teacher-todo-card
11. **course-plans 表单与日历**:补 `course-plan-form.tsx` / `course-plan-calendar.tsx` / `sortable-week-row.tsx` / `template-picker-dialog.tsx` / `course-plan-item-editor.tsx` / `course-plan-progress.tsx`
12. **homework 试卷关联**:补 `homework-assignment-exam-*`5 个文件)/ `homework-assignment-form.tsx` / `excellent-submissions.tsx` / `student-homework-review-view.tsx`
### P3按需补全
13. **adaptive-practice 练习流程**补答题核心组件answer-input / answer-result / question-card / question-content与教师分析图表5 个)
14. **leave-requests 审批**:补 `leave-review-dialog.tsx` / `leave-review-list.tsx` / `leave-request-form.tsx`
15. **elective 抽签与冲突检测**:补 `lib/lottery.ts` / `lib/schedule-conflict.ts` / `elective-course-detail.tsx` / `elective-page-layout.tsx`
16. **exams 辅助**:补 `exam-boundaries.tsx` / `exam-columns.tsx` / `hooks/use-exam-preview*.ts`4 个)/ `ai-pipeline/`5 个)/ `services/`3 个)
---
## 附录:架构差异说明
### A.1 数据层架构差异(非缺失)
CICD 采用 Next.js Server Actions 模式,每个模块包含:
- `actions.ts` / `actions-*.ts`Server Actions 入口
- `data-access.ts` / `data-access-*.ts`:数据访问层
- `schema.ts`Zod 验证
- `types.ts`:类型定义
portal-shell 采用 GraphQL hooks 模式,对应能力由:
- `src/lib/api/{module}.ts`GraphQL 查询/mutation hooks`useXxx`
- `src/lib/api/operations/{module}.graphql.ts`GraphQL operations
- `src/features/teacher/{module}/transformations.ts`:数据转换与纯函数
承担。本报告对 `actions*.ts` / `data-access*.ts` / `schema.ts` / `types.ts` 这类非组件文件**不予标记为缺失**,除非包含核心算法(如 `sm2-algorithm.ts` / `graph-layout.ts` / `trend-compute.ts` / `warning-compute.ts` / `correlation-compute.ts` / `lottery.ts` / `schedule-conflict.ts`)。
### A.2 路由结构差异
- CICD`src/app/(dashboard)/teacher/`(路由组 `(dashboard)`
- portal-shell`src/app/shell/teacher/`(路由组 `shell`
路由组差异不影响实际路径,`/teacher/*` 路径在两端一致。
### A.3 共享组件差异
CICD 使用 shadcn/ui 组件库Dialog/Select/Table/Checkbox/ScrollArea/ResizablePanel 等portal-shell 部分组件用原生 HTML 元素替代(见各文件头注释:"使用原生元素替代")。这是 portal-shell 的适配策略,不标记为缺失。
### A.4 核心算法文件迁移建议
以下 CICD 非组件文件包含核心业务算法,建议迁入 portal-shell 对应模块的 `transformations.ts` 或独立 lib
| CICD 文件 | 所属模块 | 建议迁移目标 |
| -------------------------------------------------------------------- | ---------- | -------------------------------------------------------- |
| `sm2-algorithm.ts` | error-book | `features/teacher/error-book/lib/sm2-algorithm.ts` |
| `graph-layout.ts` | textbooks | `features/teacher/knowledge-graph/lib/graph-layout.ts` |
| `trend-compute.ts` / `warning-compute.ts` / `correlation-compute.ts` | attendance | `features/teacher/attendance/transformations.ts`(合并) |
| `lottery.ts` / `schedule-conflict.ts` | elective | `features/teacher/elective/lib/` |
| `ai-pipeline/`5 文件) | exams | `features/teacher/exams/lib/ai-pipeline/` |
| `lib/scope-check.ts` / `lib/scope-filter.ts` | grades | `features/teacher/grades/lib/` |
---
**报告结束。后续迁移工作请基于本报告的"第三部分:每个模块的缺失组件清单"与"第五部分:迁移优先级建议"执行。**

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 UserDetailError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("admin.users");
useEffect(() => {
console.error("[portal-shell] admin user detail 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,9 @@
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 用户详情路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function UserDetailLoading(): React.ReactElement {
return <DetailPageSkeleton />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { UserDetailClient } from "@/features/admin/users/user-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 用户详情页ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 UserDetailClientclient component中。
*
* 数据契约user(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/iam_contract.md#user-detail
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3
*/
export default function UserDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<UserDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { MessageDetailClient } from "@/features/shared/messages/message-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 消息详情页共享路由ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P5 B3 末)
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 MessageDetailClientclient component中。
*
* 数据契约studentMessageDetail ❌ schema 无此字段 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
*/
export default function MessageDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<MessageDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { MessageComposeClient } from "@/features/shared/messages/message-compose-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 撰写消息页共享路由ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P5 B3 末)
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 MessageComposeClientclient component中。
*
* 数据契约:
* - messageRecipients ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - sendMessage ❌ schema 无此 Mutation 类型 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
*/
export default function MessageComposePage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<MessageComposeClient />
</Suspense>
);
}

View File

@@ -0,0 +1,27 @@
import { Suspense } from "react";
import { MessageGroupComposeClient } from "@/features/shared/messages/message-group-compose-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 群发消息页共享路由ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P5 B3 末)
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 MessageGroupComposeClientclient component中。
*
* 注CICD 中 group-compose 仅限教师访问portal-shell 共享路由模式下,
* 学生角色也可访问此页(如需要班级群发),权限由 route-permissions 控制。
*
* 数据契约:
* - studentClasses ✅ 已存在 → 取学生本人班级作为群发目标
* - sendMessage ❌ schema 无此 Mutation 类型 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
*/
export default function MessageGroupComposePage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<MessageGroupComposeClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { ProfileClient } from "@/features/shared/profile/profile-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 个人资料页ARCHITECTURE.md §7.3 详情页 / §9.1 B1 共享页)
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 ProfileClientclient component中。
*
* 数据契约userProfile ❌ schema 无此根字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/iam_contract.md#user-profile
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 B1 / §11.3 / §11.4
*/
export default function ProfilePage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ProfileClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { StudentSelfDiagnosticClient } from "@/features/student/diagnostic/diagnostic-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 学生诊断报告页ARCHITECTURE.md §7.3 详情页 / §9.2 B3 / §10 P3
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 StudentSelfDiagnosticClientclient component中。
*
* 数据契约studentDiagnostic(studentId) ❌ schema 无此字段 → MSW 兜底
* @contract-pending。学生页传入 "me" 作为 studentIdMSW 返回当前用户
* 诊断数据;后端补齐后由 BFF 解析 auth 上下文返回当前用户数据。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.2 / §10 P3 / §11.3 / §11.4
*/
export default function StudentDiagnosticPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<StudentSelfDiagnosticClient />
</Suspense>
);
}

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { ClassDetailClient } from "@/features/teacher/classes/class-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 我的班级详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useParams 要求)。
* 业务逻辑在 ClassDetailClientclient component复用班级详情渲染。
*
* 数据契约(混合):
* - classInfo(id: ID!) ✅ schema 真实字段classes 子图)
* - classStudents(classId) / classTeachers(classId) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#class-students
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function MyClassDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ClassDetailClient />
</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 MyClassesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("classes");
useEffect(() => {
console.error("[portal-shell] classes/my 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,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 我的班级页加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function MyClassesLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { ClassesListClient } from "@/features/teacher/classes/classes-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 我的班级列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 ClassesListClientclient component复用班级列表渲染与筛选。
*
* 数据契约classes(...) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#classes-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function MyClassesPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<ClassesListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
/**
* 班级学生列表页 → 重定向到学生管理
*
* 阅卷入口聚合在学生管理列表(/shell/teacher/students
* 本路由保留作为兼容入口,直接重定向避免展示空态。
*
* 关联teacher-NeedTodo.md §班级管理
*/
export default function ClassStudentsPage(): never {
redirect("/shell/teacher/students");
}

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 AllExamsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("exams");
useEffect(() => {
console.error("[portal-shell] exams/all 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,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 全部考试页加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function AllExamsLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

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 AllExamsPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<ExamsListClient />
</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 CreateExamError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("exams");
useEffect(() => {
console.error("[portal-shell] exams/create 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,9 @@
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 创建考试页加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function CreateExamLoading(): React.ReactElement {
return <FormPageSkeleton />;
}

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 CreateExamPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<NewExamClient />
</Suspense>
);
}

View File

@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
interface PageProps {
params: Promise<{ submissionId: string }>;
}
/**
* 阅卷详情页 → 重定向到作业提交阅卷详情
*
* 阅卷详情聚合在作业提交详情(/shell/teacher/homework/submissions/[submissionId]
* 本路由保留作为兼容入口,直接重定向避免展示空态。
*
* 关联teacher-NeedTodo.md §考试管理
*/
export default async function ExamGradingDetailPage({
params,
}: PageProps): Promise<never> {
const { submissionId } = await params;
redirect(`/shell/teacher/homework/submissions/${submissionId}`);
}

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 ExamGradingError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("exams");
useEffect(() => {
console.error("[portal-shell] exams/grading 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,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 阅卷列表页加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function ExamGradingLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
/**
* 阅卷列表页 → 重定向到作业提交列表
*
* 阅卷入口聚合在作业提交列表(/shell/teacher/homework/submissions
* 本路由保留作为兼容入口,直接重定向避免展示空态。
*
* 关联teacher-NeedTodo.md §考试管理
*/
export default function ExamGradingPage(): never {
redirect("/shell/teacher/homework/submissions");
}

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 CreateHomeworkAssignmentPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<NewHomeworkClient />
</Suspense>
);
}

View File

@@ -208,7 +208,7 @@ function AttendanceFilters({
...grades.map((g) => ({ value: g.id, label: g.name })),
]}
placeholder={t("allGrades")}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50"
/>
</div>
@@ -224,7 +224,7 @@ function AttendanceFilters({
...classes.map((cls) => ({ value: cls.id, label: cls.name })),
]}
placeholder={t("allClasses")}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50"
/>
</div>
@@ -249,7 +249,7 @@ function AttendanceFilters({
})),
]}
placeholder={t("allStatuses")}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50"
/>
</div>
@@ -261,7 +261,7 @@ function AttendanceFilters({
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50"
/>
</div>
</div>

View File

@@ -155,7 +155,7 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
icon={error ? AlertCircle : TrendingUp}
title={error ? t("errorTitle") : t("emptyTitle")}
description={error ? t("errorDescription") : t("emptyDescription")}
className="min-h-[300px]"
className="min-h-72"
/>
</CardContent>
</Card>
@@ -200,7 +200,7 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
{scatterData.length > 0 ? (
<div>
<h4 className="mb-3 text-sm font-medium">{t("scatterTitle")}</h4>
<ChartContainer config={chartConfig} className="h-[320px] w-full">
<ChartContainer config={chartConfig} className="h-80 w-full">
<ScatterChart margin={SCATTER_MARGIN}>
<CartesianGrid {...SCATTER_GRID_PROPS} />
<XAxis
@@ -275,7 +275,7 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
icon={AlertCircle}
title={t("emptyTitle")}
description={t("emptyDescription")}
className="min-h-[200px]"
className="min-h-48"
/>
)}
@@ -349,7 +349,7 @@ function CorrelationSkeleton(): React.ReactElement {
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-[320px] w-full" />
<Skeleton className="h-80 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>

View File

@@ -194,7 +194,7 @@ export function AttendanceRecordsList({
)}
</Badge>
</TableCell>
<TableCell className="max-w-[240px] truncate text-muted-foreground">
<TableCell className="max-w-xs truncate text-muted-foreground">
{truncateNote(r.note)}
</TableCell>
<TableCell className="text-muted-foreground">

View File

@@ -109,7 +109,7 @@ export function ClassComparisonCard(): React.ReactElement {
icon={error ? AlertCircle : GitCompare}
title={error ? t("errorTitle") : t("emptyTitle")}
description={error ? t("errorDescription") : t("emptyDescription")}
className="min-h-[300px]"
className="min-h-72"
/>
</CardContent>
</Card>
@@ -137,7 +137,7 @@ export function ClassComparisonCard(): React.ReactElement {
xKey="name"
yDomain={[0, 100]}
yTickFormatter={(v) => `${v}%`}
heightClassName="h-[280px]"
heightClassName="h-72"
/>
<div className="overflow-x-auto rounded-md border">
@@ -197,7 +197,7 @@ function ClassComparisonSkeleton(): React.ReactElement {
<Skeleton className="mt-2 h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-[280px] w-full" />
<Skeleton className="h-72 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>

View File

@@ -0,0 +1,97 @@
/**
* Admin Attendance 导出工具ARCHITECTURE.md §9.4 / §10 P5
*
* 前端构造 CSV 并触发下载(@contract-pending无后端导出契约时使用
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import type { AdminAttendanceRecord } from "@/lib/api/admin-p5";
import { downloadBlob } from "@/shared/lib/download";
export type AttendanceExportColumnKey =
"studentName" | "className" | "date" | "status" | "note" | "recordedBy";
export interface AttendanceColumnLabels {
studentName: string;
className: string;
date: string;
status: string;
note: string;
recordedBy: string;
statusPresent: string;
statusAbsent: string;
statusLate: string;
statusLeave: string;
}
export interface AttendanceExportColumn {
key: AttendanceExportColumnKey;
label: string;
}
export type AttendanceExportRow = Record<AttendanceExportColumnKey, string>;
function formatStatus(status: string, labels: AttendanceColumnLabels): string {
switch (status) {
case "present":
return labels.statusPresent;
case "absent":
return labels.statusAbsent;
case "late":
return labels.statusLate;
case "leave":
return labels.statusLeave;
default:
return status;
}
}
export function buildAttendanceExportColumns(
labels: AttendanceColumnLabels,
): readonly AttendanceExportColumn[] {
return [
{ key: "studentName", label: labels.studentName },
{ key: "className", label: labels.className },
{ key: "date", label: labels.date },
{ key: "status", label: labels.status },
{ key: "note", label: labels.note },
{ key: "recordedBy", label: labels.recordedBy },
];
}
export function attendanceRecordToExportRow(
record: AdminAttendanceRecord,
labels: AttendanceColumnLabels,
): AttendanceExportRow {
return {
studentName: record.studentName ?? "",
className: record.className ?? "",
date: record.date ?? "",
status: formatStatus(record.status, labels),
note: record.note ?? "",
recordedBy: record.recordedBy ?? "",
};
}
function escapeCsvField(value: string): string {
if (value === "") return "";
const needsQuote = /[",\n\r]/.test(value);
const escaped = value.replace(/"/g, '""');
return needsQuote ? '"' + escaped + '"' : escaped;
}
export function exportAttendanceToCsv(
filename: string,
records: AdminAttendanceRecord[],
labels: AttendanceColumnLabels,
): void {
const columns = buildAttendanceExportColumns(labels);
const header = columns.map((c) => escapeCsvField(c.label)).join(",");
const rows = records.map((record) => {
const row = attendanceRecordToExportRow(record, labels);
return columns.map((c) => escapeCsvField(row[c.key])).join(",");
});
const csv = "\uFEFF" + [header, ...rows].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
downloadBlob(blob, filename + ".csv");
}

View File

@@ -293,7 +293,7 @@ function LoginLogsTable({
{log.ip}
</td>
<td
className="max-w-[280px] truncate p-3 font-mono text-xs text-muted-foreground"
className="max-w-xs truncate p-3 font-mono text-xs text-muted-foreground"
title={log.userAgent}
>
{log.userAgent}

View File

@@ -200,7 +200,7 @@ export function CoursePlanItemEditor({
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("contentPlaceholder")}
className="min-h-[100px]"
className="min-h-24"
/>
</div>

View File

@@ -4,7 +4,7 @@
* 错题详情对话框(迁移自 CICD error-book-detail-dialog.tsx
*
* 适配 portal-shell
* - 用原生轻量模态fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
* - 使用 shadcn Dialog 组件
* - 数据通过 useErrorBookDetail hook@contract-pending MSW 兜底)拉取
* - 仅展示详情(无 archive/delete/saveNote 等 server action
* - 错误处理走 notify.error()
@@ -28,6 +28,14 @@ import { useErrorBookDetail } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Separator } from "@/shared/components/ui/separator";
export interface ErrorBookDetailDialogProps {
@@ -58,65 +66,31 @@ export function ErrorBookDetailDialog({
}
}, [error, tCommon]);
// ESC 键关闭
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
if (!open) return <></>;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="error-book-detail-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border bg-card shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-start justify-between border-b p-6 pb-4">
<div className="flex-1">
<h2
id="error-book-detail-title"
className="flex items-center gap-2 text-lg font-semibold"
>
<BookOpen className="size-5" />
{t("title")}
</h2>
{data ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{data.subjectName ? (
<Badge variant="outline">{data.subjectName}</Badge>
) : null}
{data.knowledgePointTitle ? (
<Badge variant="secondary">{data.knowledgePointTitle}</Badge>
) : null}
{data.className ? (
<span className="text-muted-foreground">
{data.className}
</span>
) : null}
</div>
) : null}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
aria-label={t("close")}
>
</Button>
</div>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden p-0">
<DialogHeader className="border-b p-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<BookOpen className="size-5" />
{t("title")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("title")}
</DialogDescription>
{data ? (
<div className="flex flex-wrap items-center gap-2 text-xs">
{data.subjectName ? (
<Badge variant="outline">{data.subjectName}</Badge>
) : null}
{data.knowledgePointTitle ? (
<Badge variant="secondary">{data.knowledgePointTitle}</Badge>
) : null}
{data.className ? (
<span className="text-muted-foreground">{data.className}</span>
) : null}
</div>
) : null}
</DialogHeader>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-6">
@@ -226,14 +200,13 @@ export function ErrorBookDetailDialog({
)}
</div>
{/* 底部 */}
<div className="flex justify-end gap-2 border-t p-4">
<DialogFooter className="border-t p-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("close")}
</Button>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -14,11 +14,21 @@
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5
*/
import { Trash2, X } from "lucide-react";
import { Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useBatchDeleteFiles } from "@/lib/api";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
@@ -155,50 +165,27 @@ function ConfirmDialog({
onCancel,
onConfirm,
}: ConfirmDialogProps): React.ReactElement {
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
if (e.key === "Escape" && !loading) onCancel();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [loading, onCancel]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => {
if (!loading) onCancel();
<AlertDialog
open={true}
onOpenChange={(o) => {
if (!o && !loading) onCancel();
}}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div
className="w-full max-w-md rounded-lg bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
type="button"
onClick={onCancel}
disabled={loading}
className="text-muted-foreground hover:text-foreground"
aria-label={cancelLabel}
>
<X className="size-5" />
</button>
</div>
<p className="mt-3 text-sm text-muted-foreground">{description}</p>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={onCancel} disabled={loading}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading} onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} disabled={loading}>
{loading ? confirmingLabel : submitLabel}
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -4,12 +4,11 @@
* 教案软删除确认对话框(基于 shadcn AlertDialogARCHITECTURE.md §7.3 / §9.4
*
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
* 结构AlertDialog + AlertDialogContent
* 交互ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
* 交互ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体(由 Radix Dialog 提供)
*
* 关联ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
*/
import { AlertTriangle, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import {
@@ -30,7 +29,7 @@ export interface DeleteConfirmDialogProps {
onConfirm: () => void;
/** 取消回调(点击遮罩 / ESC / 取消按钮) */
onCancel: () => void;
/** 删除进行中(禁用按钮、隐藏 spinner */
/** 删除进行中(禁用按钮、显示 spinner */
loading?: boolean;
}
@@ -44,39 +43,32 @@ export function DeleteConfirmDialog({
onConfirm,
onCancel,
loading = false,
}: DeleteConfirmDialogProps): React.ReactElement | null {
}: DeleteConfirmDialogProps): React.ReactElement {
const t = useTranslations("admin.lessonPlans.delete");
const handleOpenChange = (next: boolean): void => {
// 删除进行中时禁止关闭(与原 ESC 行为一致)
if (loading) return;
if (!next) onCancel();
};
const handleConfirm = (e: React.MouseEvent): void => {
e.preventDefault();
onConfirm();
};
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !loading) {
onCancel();
}
}}
>
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</span>
<AlertDialogHeader>
<AlertDialogTitle>{t("title")}</AlertDialogTitle>
<AlertDialogDescription>{t("description")}</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogHeader>
<AlertDialogTitle>{t("title")}</AlertDialogTitle>
<AlertDialogDescription>{t("description")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
<AlertDialogCancel disabled={loading} onClick={onCancel}>
{t("cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={loading}
>
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
{loading ? (
<>
<Loader2 className="size-4 animate-spin" />

View File

@@ -0,0 +1,116 @@
/**
* Admin Lesson Plans 导出工具ARCHITECTURE.md §9.4 / §10 P5
*
* 前端构造 CSV 并触发下载(@contract-pending无后端导出契约时使用
* 后端补齐导出契约后,可改为调用后端接口获取 CSV。
*
* 设计原则:
* - `lessonPlanToExportRow` 为纯函数,便于单测;不直接依赖 i18n
* 状态文本由调用方通过 columnLabels 传入
* - `exportLessonPlansToCsv` 执行客户端下载,调用方处理 notify
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import type { AdminLessonPlanListItem } from "@/lib/api";
import { downloadBlob } from "@/shared/lib/download";
/** 导出列标识(与 LessonPlanExportRow 的 key 对应) */
export type LessonPlanExportColumnKey =
| "title"
| "subject"
| "grade"
| "className"
| "status"
| "teacher"
| "createdAt";
/** 列标签映射(由调用方传入已本地化的字符串) */
export interface LessonPlanColumnLabels {
title: string;
subject: string;
grade: string;
className: string;
status: string;
teacher: string;
createdAt: string;
statusDraft: string;
statusPublished: string;
statusArchived: string;
statusSubmitted: string;
}
/** 导出列定义 */
export interface LessonPlanExportColumn {
key: LessonPlanExportColumnKey;
label: string;
}
/** 导出行(键值对,键对应 LessonPlanExportColumn.key */
export type LessonPlanExportRow = Record<LessonPlanExportColumnKey, string>;
function formatStatus(status: string, labels: LessonPlanColumnLabels): string {
switch (status) {
case "DRAFT":
return labels.statusDraft;
case "PUBLISHED":
return labels.statusPublished;
case "ARCHIVED":
return labels.statusArchived;
case "SUBMITTED":
return labels.statusSubmitted;
default:
return status;
}
}
export function buildLessonPlanExportColumns(
labels: LessonPlanColumnLabels,
): readonly LessonPlanExportColumn[] {
return [
{ key: "title", label: labels.title },
{ key: "subject", label: labels.subject },
{ key: "grade", label: labels.grade },
{ key: "className", label: labels.className },
{ key: "status", label: labels.status },
{ key: "teacher", label: labels.teacher },
{ key: "createdAt", label: labels.createdAt },
];
}
export function lessonPlanToExportRow(
plan: AdminLessonPlanListItem,
labels: LessonPlanColumnLabels,
): LessonPlanExportRow {
return {
title: plan.title ?? "",
subject: plan.subjectName || plan.subjectId || "",
grade: "",
className: plan.className ?? "",
status: formatStatus(plan.status, labels),
teacher: plan.teacherName ?? "",
createdAt: plan.createdAt ?? "",
};
}
function escapeCsvField(value: string): string {
if (value === "") return "";
const needsQuote = /[",\n\r]/.test(value);
const escaped = value.replace(/"/g, '""');
return needsQuote ? `"${escaped}"` : escaped;
}
export function exportLessonPlansToCsv(
filename: string,
plans: AdminLessonPlanListItem[],
labels: LessonPlanColumnLabels,
): void {
const columns = buildLessonPlanExportColumns(labels);
const header = columns.map((c) => escapeCsvField(c.label)).join(",");
const rows = plans.map((plan) => {
const row = lessonPlanToExportRow(plan, labels);
return columns.map((c) => escapeCsvField(row[c.key])).join(",");
});
const csv = "\uFEFF" + [header, ...rows].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
downloadBlob(blob, `${filename}.csv`);
}

View File

@@ -0,0 +1,110 @@
"use client";
/**
* 组织节点详情对话框ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5
*
* 数据契约:
* - 详情数据复用树中的 OrgNode 对象(@contract-pending单查契约待补齐
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { Building2 } from "lucide-react";
import { useTranslations } from "next-intl";
import type { OrgNode } from "@/lib/api/admin-p5";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { DetailField, DetailSection } from "@/shared/components/page-templates";
import {
countChildren,
formatMemberCount,
formatOrgType,
orgTypeToBadgeClass,
} from "@/features/admin/organization/transformations";
/**
* 组织节点详情对话框。受控组件,由父组件管理 open 状态与选中数据。
*/
export function OrganizationDetailDialog({
open,
onOpenChange,
node,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
node: OrgNode | null;
}): React.ReactElement {
const t = useTranslations("admin.organization.detail");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Building2 className="size-5" />
{t("title")}
</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
{node ? <OrganizationDetailBody node={node} /> : null}
</DialogContent>
</Dialog>
);
}
/**
* 详情内容区(基本信息 + 结构信息)。
*/
function OrganizationDetailBody({
node,
}: {
node: OrgNode;
}): React.ReactElement {
const t = useTranslations("admin.organization.detail");
return (
<div className="space-y-4">
<DetailSection title={t("sectionBasic")}>
<DetailField label={t("fieldName")} value={node.name} />
<DetailField
label={t("fieldType")}
value={<TypeBadge type={node.type} />}
/>
<DetailField label={t("fieldId")} value={node.id} />
<DetailField label={t("fieldParentId")} value={node.parentId ?? "-"} />
</DetailSection>
<DetailSection title={t("sectionStructure")}>
<DetailField
label={t("fieldMemberCount")}
value={formatMemberCount(node.memberCount)}
/>
<DetailField
label={t("fieldChildrenCount")}
value={String(countChildren(node))}
/>
</DetailSection>
</div>
);
}
/**
* 类型徽章(按节点类型色阶展示)。
*/
function TypeBadge({ type }: { type: string }): React.ReactElement {
const label = formatOrgType(type);
const cls = orgTypeToBadgeClass(type);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -33,11 +33,13 @@ import {
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import {
activeToBadgeClass,
builtinToBadgeClass,
@@ -318,12 +320,20 @@ function PluginEditDialog({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-base">{t("editTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Dialog
open={true}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-base">{t("editTitle")}</DialogTitle>
<DialogDescription className="sr-only">
{t("editTitle")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FormField label={t("colPluginId")}>
<Input type="text" value={plugin.pluginId} readOnly />
</FormField>
@@ -366,17 +376,17 @@ function PluginEditDialog({
<p className="text-xs text-destructive">{draftError}</p>
) : null}
</FormField>
<div className="flex justify-end gap-2 border-t pt-4">
<DialogFooter className="border-t pt-4">
<Button variant="outline" onClick={onClose}>
{t("cancelEdit")}
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{t("saveProps")}
</Button>
</div>
</CardContent>
</Card>
</div>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -23,6 +23,16 @@ import { useState } from "react";
import { useTranslations } from "next-intl";
import { useBatchDeleteQuestions } from "@/lib/api";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
@@ -132,37 +142,28 @@ function ConfirmDeleteDialog({
}): React.ReactElement {
const t = useTranslations("admin.questions.batch");
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
<AlertDialog
open={true}
onOpenChange={(o) => {
if (!o && !loading) onCancel();
}}
>
<div
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("confirmTitle")}</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t("confirmDescription", { count })}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("confirmDescription", { count })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading} onClick={onCancel}>
{t("confirmCancel")}
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={loading}
>
</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} disabled={loading}>
{loading ? t("confirming") : t("confirmSubmit")}
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -23,6 +23,14 @@ import { useTranslations } from "next-intl";
import { useCreateQuestion } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
@@ -127,16 +135,17 @@ export function CreateQuestionDialog({
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleClose}
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) handleClose();
}}
>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("title")}</h2>
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("fieldType")} required>
<Select
@@ -147,7 +156,7 @@ export function CreateQuestionDialog({
value: opt,
label: t(`types.${opt}`),
}))}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50"
/>
</FormField>
@@ -159,7 +168,7 @@ export function CreateQuestionDialog({
rows={4}
required
aria-label={t("fieldContent")}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50"
/>
</FormField>
@@ -171,7 +180,7 @@ export function CreateQuestionDialog({
rows={2}
required
aria-label={t("fieldAnswer")}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50"
/>
</FormField>
@@ -182,7 +191,7 @@ export function CreateQuestionDialog({
maxLength={1000}
rows={2}
aria-label={t("fieldExplanation")}
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50"
/>
</FormField>
@@ -196,7 +205,7 @@ export function CreateQuestionDialog({
value: String(opt.value),
label: t(opt.labelKey),
}))}
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50"
/>
</FormField>
@@ -220,7 +229,7 @@ export function CreateQuestionDialog({
/>
</FormField>
<div className="flex justify-end gap-2">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -232,10 +241,10 @@ export function CreateQuestionDialog({
<Button type="submit" disabled={createMutation.loading}>
{createMutation.loading ? t("submitting") : t("submit")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,7 +7,7 @@
* - adminQuestion(id):❌ schema 无 → MSW 兜底(@contract-pending
*
* 适配 portal-shell
* - 用原生轻量模态fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
* - 使用 shadcn Dialog 组件
* - 数据通过 useAdminQuestion hook 拉取
* - 错误处理走 notify.error()
*
@@ -31,6 +31,14 @@ import { useAdminQuestion } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Separator } from "@/shared/components/ui/separator";
import {
difficultyToColorClass,
@@ -69,63 +77,31 @@ export function QuestionDetailDialog({
}
}, [error, tCommon]);
// ESC 键关闭
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
if (!open) return <></>;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="question-detail-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border bg-card shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-start justify-between border-b p-6 pb-4">
<div className="flex-1">
<h2
id="question-detail-title"
className="flex items-center gap-2 text-lg font-semibold"
>
<HelpCircle className="size-5" />
{t("title")}
</h2>
{data ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">{formatQuestionType(data.type)}</Badge>
<Badge variant="secondary">
{formatQuestionStatus(data.status)}
</Badge>
{data.subjectName ? (
<span className="text-muted-foreground">
{data.subjectName}
</span>
) : null}
</div>
) : null}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
aria-label={t("close")}
>
</Button>
</div>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden p-0">
<DialogHeader className="border-b p-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<HelpCircle className="size-5" />
{t("title")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("title")}
</DialogDescription>
{data ? (
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">{formatQuestionType(data.type)}</Badge>
<Badge variant="secondary">
{formatQuestionStatus(data.status)}
</Badge>
{data.subjectName ? (
<span className="text-muted-foreground">
{data.subjectName}
</span>
) : null}
</div>
) : null}
</DialogHeader>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-6">
@@ -274,14 +250,13 @@ export function QuestionDetailDialog({
)}
</div>
{/* 底部 */}
<div className="flex justify-end gap-2 border-t p-4">
<DialogFooter className="border-t p-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("close")}
</Button>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -38,6 +38,7 @@ import {
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Textarea } from "@/shared/components/ui/textarea";
import {
ListPageShell,
ListPageSkeleton,
@@ -61,6 +62,8 @@ export function ScheduleChangesClient(): React.ReactElement {
const { data: entries } = useAdminScheduleEntries();
const { run: approveChange, loading: approving } = useApproveScheduleChange();
const { run: rejectChange, loading: rejecting } = useRejectScheduleChange();
const [rejectId, setRejectId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState("");
const changes = data?.items ?? [];
const entriesList = entries ?? [];
@@ -112,19 +115,26 @@ export function ScheduleChangesClient(): React.ReactElement {
};
const handleReject = async (id: string): Promise<void> => {
const reason = window.prompt(t("rejectConfirm"));
if (reason === null) return;
const trimmed = reason.trim();
setRejectId(id);
setRejectReason("");
};
const confirmReject = async (): Promise<void> => {
if (!rejectId) return;
const trimmed = rejectReason.trim();
if (!trimmed) {
notify.error(t("rejectError"));
return;
}
try {
await rejectChange(id, trimmed);
await rejectChange(rejectId, trimmed);
notify.success(t("rejectSuccess"));
await refetch();
} catch (err) {
notify.error(`${t("rejectError")}: ${String(err)}`);
} finally {
setRejectId(null);
setRejectReason("");
}
};
@@ -166,6 +176,45 @@ export function ScheduleChangesClient(): React.ReactElement {
/>
<ScheduleGridCard entries={entriesList} />
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
<AlertDialog
open={rejectId !== null}
onOpenChange={(open) => {
if (!open) {
setRejectId(null);
setRejectReason("");
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("rejectButton")}</AlertDialogTitle>
<AlertDialogDescription>
{t("rejectConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder={t("rejectConfirm")}
/>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setRejectId(null);
setRejectReason("");
}}
>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={rejecting}
onClick={() => void confirmReject()}
>
{t("rejectButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ListPageShell>
);
}

View File

@@ -26,8 +26,26 @@ import {
type ClassInvitationCode,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { FormField } from "@/features/admin/school/schools-client";
@@ -97,23 +115,25 @@ export function ClassInvitationManagerDialog({
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<div
className="flex max-h-[90vh] w-full max-w-3xl flex-col rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{t("title")}</h2>
{className ? (
<p className="mt-1 text-xs text-muted-foreground">
{t("classLabel")}: {className}
</p>
) : null}
</div>
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col p-6">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription className="sr-only">
{t("title")}
</DialogDescription>
{className ? (
<p className="text-xs text-muted-foreground">
{t("classLabel")}: {className}
</p>
) : null}
</DialogHeader>
<div className="flex justify-end">
<Button size="sm" onClick={() => setGenerateOpen(true)}>
<Plus className="mr-1.5 size-4" />
{t("generate")}
@@ -174,7 +194,7 @@ export function ClassInvitationManagerDialog({
? new Date(record.expiresAt).toLocaleString()
: t("neverExpires")}
</td>
<td className="max-w-[200px] truncate p-3 text-sm text-muted-foreground">
<td className="max-w-xs truncate p-3 text-sm text-muted-foreground">
{record.note ?? "-"}
</td>
<td className="p-3 text-right">
@@ -211,11 +231,11 @@ export function ClassInvitationManagerDialog({
)}
</div>
<div className="mt-4 flex justify-end">
<DialogFooter className="mt-4">
<Button variant="outline" onClick={onClose}>
{tCommon("form.close")}
</Button>
</div>
</DialogFooter>
<GenerateCodeDialog
open={generateOpen}
@@ -225,43 +245,40 @@ export function ClassInvitationManagerDialog({
/>
{revokeTarget ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setRevokeTarget(null)}
<AlertDialog
open={true}
onOpenChange={(o) => {
if (!o && !revokeMutation.loading) setRevokeTarget(null);
}}
>
<div
className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("revoke")}</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t("revokeConfirm")}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setRevokeTarget(null)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("revoke")}</AlertDialogTitle>
<AlertDialogDescription>
{t("revokeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={revokeMutation.loading}
onClick={() => setRevokeTarget(null)}
>
{tCommon("form.cancel")}
</Button>
<Button
type="button"
variant="destructive"
</AlertDialogCancel>
<AlertDialogAction
disabled={revokeMutation.loading}
onClick={() => void handleRevoke()}
>
{revokeMutation.loading
? tCommon("form.processing")
: t("revoke")}
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -344,20 +361,19 @@ function GenerateCodeDialog({
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<div
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-1 text-lg font-semibold">
{t("generateWithCustom")}
</h2>
<p className="mb-4 text-xs text-muted-foreground">
{t("defaultDuration")} · {t("defaultMaxUses")}
</p>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("generateWithCustom")}</DialogTitle>
<DialogDescription>
{t("defaultDuration")} · {t("defaultMaxUses")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("expiresInHours")}>
<Input
@@ -386,7 +402,7 @@ function GenerateCodeDialog({
maxLength={255}
/>
</FormField>
<div className="flex justify-end gap-2">
<DialogFooter>
<Button
type="button"
variant="outline"
@@ -400,9 +416,9 @@ function GenerateCodeDialog({
? tCommon("form.processing")
: t("generate")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,7 +21,25 @@ import {
type ClassScheduleItem,
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Select } from "@/shared/components/ui/select";
import { FormField } from "@/features/admin/school/schools-client";
@@ -91,23 +109,25 @@ export function ScheduleManagerDialog({
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<div
className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{t("manager.title")}</h2>
{className ? (
<p className="mt-1 text-xs text-muted-foreground">
{t("form.classLabel")}: {className}
</p>
) : null}
</div>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col p-6">
<DialogHeader>
<DialogTitle>{t("manager.title")}</DialogTitle>
<DialogDescription className="sr-only">
{t("manager.title")}
</DialogDescription>
{className ? (
<p className="text-xs text-muted-foreground">
{t("form.classLabel")}: {className}
</p>
) : null}
</DialogHeader>
<div className="flex justify-end">
<Button size="sm" onClick={handleAdd}>
<Plus className="mr-1.5 size-4" />
{t("manager.add")}
@@ -202,11 +222,11 @@ export function ScheduleManagerDialog({
)}
</div>
<div className="mt-4 flex justify-end">
<DialogFooter className="mt-4">
<Button variant="outline" onClick={onClose}>
{tCommon("button.cancel")}
</Button>
</div>
</DialogFooter>
<ScheduleFormDialog
open={formOpen}
@@ -225,8 +245,8 @@ export function ScheduleManagerDialog({
onDeleted={handleDeleted}
/>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -329,20 +349,22 @@ function ScheduleFormDialog({
const title = mode === "edit" ? t("form.titleEdit") : t("form.titleCreate");
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-4 text-lg font-semibold">{title}</h2>
{className ? (
<p className="mb-4 text-xs text-muted-foreground">
{t("form.classLabel")}: {className}
</p>
) : null}
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{title}</DialogDescription>
{className ? (
<p className="text-xs text-muted-foreground">
{t("form.classLabel")}: {className}
</p>
) : null}
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
@@ -415,7 +437,7 @@ function ScheduleFormDialog({
</FormField>
</div>
<div className="flex justify-end gap-2 pt-2">
<DialogFooter className="pt-2">
<Button
type="button"
variant="outline"
@@ -427,10 +449,10 @@ function ScheduleFormDialog({
<Button type="submit" disabled={loading}>
{mode === "edit" ? t("form.save") : t("form.create")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -464,41 +486,38 @@ function ScheduleDeleteDialog({
};
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
<AlertDialog
open={true}
onOpenChange={(o) => {
if (!o && !deleteMutation.loading) onClose();
}}
>
<div
className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-semibold">{t("form.deleteTitle")}</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t("form.deleteMessage", {
weekday: t(`weekday.${target.weekday}`),
period: target.period,
subject: target.subjectName,
})}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("form.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("form.deleteMessage", {
weekday: t(`weekday.${target.weekday}`),
period: target.period,
subject: target.subjectName,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={deleteMutation.loading}
onClick={onClose}
>
{tCommon("button.cancel")}
</Button>
<Button
type="button"
variant="destructive"
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteMutation.loading}
onClick={() => void handleDelete()}
>
{t("form.deleteConfirm")}
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -30,6 +30,14 @@ import {
} from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import { Input } from "@/shared/components/ui/input";
@@ -373,17 +381,21 @@ function DepartmentFormDialog({
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
<Dialog
open={true}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<div
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-4 text-lg font-semibold">
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</h2>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</DialogTitle>
<DialogDescription className="sr-only">
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label={t("form.fieldName")} required>
<Input
@@ -422,16 +434,16 @@ function DepartmentFormDialog({
onChange={(e) => setDescription(e.target.value)}
/>
</FormField>
<div className="flex justify-end gap-2">
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{t("form.submit")}
</Button>
</div>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -321,7 +321,7 @@ export function GradeInsightsClient(): React.ReactElement {
icon={BarChart3}
title={t("classRanking.empty")}
description={t("classRanking.emptyDescription")}
className="min-h-[240px] bg-transparent"
className="min-h-60 bg-transparent"
/>
) : (
<div className="rounded-md border">

View File

@@ -0,0 +1,107 @@
"use client";
/**
* 学生详情对话框ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5
*
* 数据契约:
* - 详情数据复用列表中的 AdminStudent 对象(@contract-pending单查契约待补齐
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { GraduationCap } from "lucide-react";
import { useTranslations } from "next-intl";
import type { AdminStudent } from "@/lib/api/admin-p5";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { DetailField, DetailSection } from "@/shared/components/page-templates";
import {
formatStudentDate,
formatStudentStatus,
studentStatusToBadgeClass,
} from "@/features/admin/students/transformations";
/**
* 学生详情对话框。受控组件,由父组件管理 open 状态与选中数据。
*/
export function StudentDetailDialog({
open,
onOpenChange,
student,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
student: AdminStudent | null;
}): React.ReactElement {
const t = useTranslations("admin.students.detail");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GraduationCap className="size-5" />
{t("title")}
</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
{student ? <StudentDetailBody student={student} /> : null}
</DialogContent>
</Dialog>
);
}
/**
* 详情内容区(基本信息 + 学籍信息)。
*/
function StudentDetailBody({
student,
}: {
student: AdminStudent;
}): React.ReactElement {
const t = useTranslations("admin.students.detail");
return (
<div className="space-y-4">
<DetailSection title={t("sectionBasic")}>
<DetailField label={t("fieldName")} value={student.name} />
<DetailField label={t("fieldStudentNo")} value={student.studentNo} />
<DetailField
label={t("fieldStatus")}
value={<StatusBadge status={student.status} />}
/>
</DetailSection>
<DetailSection title={t("sectionAcademic")}>
<DetailField label={t("fieldGrade")} value={student.gradeName} />
<DetailField label={t("fieldClass")} value={student.className} />
<DetailField
label={t("fieldEnrolledAt")}
value={formatStudentDate(student.enrolledAt)}
/>
<DetailField label={t("fieldId")} value={student.id} />
</DetailSection>
</div>
);
}
/**
* 状态徽章(按状态色阶展示)。
*/
function StatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatStudentStatus(status);
const cls = studentStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -4,18 +4,20 @@
* 学生管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5
*
* 数据契约:
* - 列表查询 adminStudents(filter, pagination) ❌ schema 无 → MSW 兜底(@contract-pending
* - 列表查询 adminStudents(filter) ❌ schema 无 → MSW 兜底(@contract-pending
* - 年级/班级选项 grades/adminClasses ❌ schema 无 → MSW 兜底(@contract-pending
*
* URL 状态:?search=&gradeId=&classId=&page=
*
* 分页:全客户端切片(与 announcements 模块对齐,@contract-pendingMSW 兜底)
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { GraduationCap } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
@@ -32,7 +34,6 @@ import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
formatStudentDate,
formatStudentStatus,
@@ -40,9 +41,10 @@ import {
studentStatusToBadgeClass,
truncateStudentName,
} from "@/features/admin/students/transformations";
import { StudentDetailDialog } from "@/features/admin/students/student-detail-dialog";
/** 每页条数(@contract-pendingMSW 兜底,真实分页待契约就绪) */
const PAGE_SIZE = 20;
/** 客户端分页每页条数(@contract-pendingMSW 兜底,真实分页待契约就绪) */
const PAGE_SIZE = 10;
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
@@ -54,13 +56,18 @@ export function StudentsListClient(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [detailOpen, setDetailOpen] = useState(false);
const [selectedStudent, setSelectedStudent] = useState<AdminStudent | null>(
null,
);
const search = searchParams.get("search") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
const classId = searchParams.get("classId") ?? "";
const pageParam = Number(searchParams.get("page") ?? "1");
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
// @contract-pendingMSW 兜底
// @contract-pendingMSW 兜底,全量拉取后客户端切片
const { data, loading, error } = useAdminStudents({
gradeId: gradeId || null,
classId: classId || null,
@@ -76,14 +83,18 @@ export function StudentsListClient(): React.ReactElement {
return all.filter((c) => c.gradeId === gradeId);
}, [classesData, gradeId]);
// 客户端二次过滤 + 切片(@contract-pendingMSW 兜底,真实分页待契约就绪)
const filteredItems = useMemo<AdminStudent[]>(() => {
const items = data?.items ?? [];
return items.filter((s) => matchAdminStudentSearch(s, search));
}, [data, search]);
const total = data?.total ?? filteredItems.length;
const hasNext = page * PAGE_SIZE < total;
const hasPrev = page > 1;
const pagedItems = useMemo(
() => filteredItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredItems, page],
);
const total = filteredItems.length;
const updateQuery = (key: string, value: string, resetPage = false): void => {
const params = new URLSearchParams(searchParams.toString());
@@ -126,6 +137,11 @@ export function StudentsListClient(): React.ReactElement {
});
};
const openDetail = (student: AdminStudent): void => {
setSelectedStudent(student);
setDetailOpen(true);
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
@@ -165,23 +181,21 @@ export function StudentsListClient(): React.ReactElement {
value={gradeId}
onValueChange={(v) => handleGradeChange(v)}
aria-label={t("list.gradeFilter")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
options={[
{ value: "", label: t("list.allGrades") },
...gradeOptions.map((g) => ({ value: g.id, label: g.name })),
]}
placeholder={t("list.allGrades")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
/>
<Select
value={classId}
onValueChange={(v) => updateQuery("classId", v, true)}
aria-label={t("list.classFilter")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
options={[
{ value: "", label: t("list.allClasses") },
...classOptions.map((c) => ({ value: c.id, label: c.name })),
]}
placeholder={t("list.allClasses")}
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
/>
</>
}
@@ -191,31 +205,20 @@ export function StudentsListClient(): React.ReactElement {
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-3 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => goToPage(page - 1)}
disabled={!hasPrev}
className="rounded-md border px-2 py-1 disabled:opacity-40"
>
</button>
<span className="px-2 py-1 tabular-nums">{page}</span>
<button
type="button"
onClick={() => goToPage(page + 1)}
disabled={!hasNext}
className="rounded-md border px-2 py-1 disabled:opacity-40"
>
</button>
</div>
</div>
<PaginationBar
total={total}
page={page}
pageSize={PAGE_SIZE}
onNavigate={goToPage}
/>
}
>
<StudentsTable items={filteredItems} />
<StudentsTable items={pagedItems} onViewDetail={openDetail} />
<StudentDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
student={selectedStudent}
/>
</ListPageShell>
);
}
@@ -225,8 +228,10 @@ export function StudentsListClient(): React.ReactElement {
*/
function StudentsTable({
items,
onViewDetail,
}: {
items: AdminStudent[];
onViewDetail: (student: AdminStudent) => void;
}): React.ReactElement {
const t = useTranslations("admin.students");
return (
@@ -268,7 +273,7 @@ function StudentsTable({
<Button
variant="ghost"
size="sm"
onClick={() => notify.info(t("list.mswNotice"))}
onClick={() => onViewDetail(s)}
>
{t("list.viewDetail")}
</Button>
@@ -295,3 +300,92 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
</span>
);
}
/**
* 分页条(页码按钮 + 上一页/下一页 + 总数)。
* 总页数 > 7 时使用窗口策略(首末页 + 当前页 ±1 + 省略号)。
*/
function PaginationBar({
total,
page,
pageSize,
onNavigate,
}: {
total: number;
page: number;
pageSize: number;
onNavigate: (page: number) => void;
}): React.ReactElement {
const t = useTranslations("admin.students");
const tCommon = useTranslations("common");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(Math.max(1, page), totalPages);
const pages: Array<number | "ellipsis"> = (() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const result: Array<number | "ellipsis"> = [1];
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) result.push("ellipsis");
for (let i = start; i <= end; i++) result.push(i);
if (end < totalPages - 1) result.push("ellipsis");
result.push(totalPages);
return result;
})();
if (total === 0) {
return (
<div className="flex items-center justify-end text-sm text-muted-foreground">
<span>{t("list.total", { count: 0 })}</span>
</div>
);
}
return (
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: total })}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => onNavigate(currentPage - 1)}
>
{tCommon("button.prev")}
</Button>
{pages.map((p, idx) =>
p === "ellipsis" ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === currentPage ? "default" : "outline"}
size="sm"
disabled={p === currentPage}
onClick={() => onNavigate(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => onNavigate(currentPage + 1)}
>
{tCommon("button.next")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
/**
* 教师详情对话框ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5
*
* 数据契约:
* - 详情数据复用列表中的 AdminTeacher 对象(@contract-pending单查契约待补齐
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { School } from "lucide-react";
import { useTranslations } from "next-intl";
import type { AdminTeacher } from "@/lib/api/admin-p5";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { DetailField, DetailSection } from "@/shared/components/page-templates";
import {
formatTeacherCount,
formatTeacherStatus,
teacherStatusToBadgeClass,
} from "@/features/admin/teachers/transformations";
/**
* 教师详情对话框。受控组件,由父组件管理 open 状态与选中数据。
*/
export function TeacherDetailDialog({
open,
onOpenChange,
teacher,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
teacher: AdminTeacher | null;
}): React.ReactElement {
const t = useTranslations("admin.teachers.detail");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<School className="size-5" />
{t("title")}
</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
{teacher ? <TeacherDetailBody teacher={teacher} /> : null}
</DialogContent>
</Dialog>
);
}
/**
* 详情内容区(基本信息 + 教学信息)。
*/
function TeacherDetailBody({
teacher,
}: {
teacher: AdminTeacher;
}): React.ReactElement {
const t = useTranslations("admin.teachers.detail");
return (
<div className="space-y-4">
<DetailSection title={t("sectionBasic")}>
<DetailField label={t("fieldName")} value={teacher.name} />
<DetailField label={t("fieldStaffNo")} value={teacher.staffNo} />
<DetailField label={t("fieldEmail")} value={teacher.email} />
<DetailField
label={t("fieldStatus")}
value={<StatusBadge status={teacher.status} />}
/>
</DetailSection>
<DetailSection title={t("sectionTeaching")}>
<DetailField label={t("fieldDepartment")} value={teacher.department} />
<DetailField label={t("fieldTitle")} value={teacher.title} />
<DetailField
label={t("fieldClassCount")}
value={formatTeacherCount(teacher.classCount)}
/>
<DetailField
label={t("fieldSubjectCount")}
value={String(teacher.subjectCount)}
/>
<DetailField label={t("fieldId")} value={teacher.id} />
</DetailSection>
</div>
);
}
/**
* 状态徽章(按状态色阶展示)。
*/
function StatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatTeacherStatus(status);
const cls = teacherStatusToBadgeClass(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,400 @@
"use client";
/**
* 用户详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.4 / §10 P5
*
* 数据契约:
* - 单查 user(id: ID!) ❌ schema 无 → MSW 兜底(@contract-pending
* - mutation updateUser(id, input) ❌ → MSW 兜底
* - mutation assignUserRoles(userId, roleNames) ❌ → MSW 兜底(复用 UserRoleAssignDialog
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.4 / §10 P5 / §11.3
*/
import { Pencil, ShieldAlert, UserCog, Users } from "lucide-react";
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState, type FormEvent } from "react";
import { useTranslations } from "next-intl";
import { useRoles, useUser, useUpdateUser, type User } from "@/lib/api";
import type { UserUpdateInput } from "@/lib/api";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Select } from "@/shared/components/ui/select";
import { notify } from "@/shared/lib/notify";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
UserRoleAssignDialog,
type AssignableRole,
} from "@/features/admin/users/user-role-assign-dialog";
import {
USER_ROLE_OPTIONS,
formatUserDate,
formatUserRole,
formatUserStatus,
userRoleToBadgeClass,
userStatusToBadgeClass,
} from "@/features/admin/users/transformations";
/** 用户状态可选值(与 URL ?status= 对齐) */
const USER_STATUS_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: "active", label: "启用" },
{ value: "suspended", label: "停用" },
{ value: "inactive", label: "未激活" },
];
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function UserDetailClient(): React.ReactElement {
const t = useTranslations("admin.users.detail");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const userId = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useUser(userId);
// 拉取角色列表供多角色分配对话框使用
const { data: rolesData } = useRoles();
const [editOpen, setEditOpen] = useState(false);
const [assignOpen, setAssignOpen] = useState(false);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
) : undefined;
// 将 roles 数据映射为 AssignableRole与列表页对齐
const assignableRoles: AssignableRole[] = rolesData
? rolesData.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? "",
isSystem: r.isLocked ?? false,
isEnabled: r.isEnabled ?? true,
}))
: [];
const currentRoleNames =
data?.roles && data.roles.length > 0 ? data.roles : data ? [data.role] : [];
return (
<DetailPageShell
title={data?.name ?? t("title")}
description={data ? data.email : undefined}
icon={<Users className="size-6" />}
backHref="/shell/admin/users"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("notFound")}
</div>
) : undefined
}
actions={
data ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setAssignOpen(true)}
>
<UserCog className="mr-2 size-4" />
{t("assignRolesButton")}
</Button>
<Button type="button" onClick={() => setEditOpen(true)}>
<Pencil className="mr-2 size-4" />
{t("editButton")}
</Button>
</div>
) : undefined
}
>
{data ? <UserDetailBody user={data} /> : null}
{data ? (
<UserEditDialog
open={editOpen}
onOpenChange={setEditOpen}
user={data}
/>
) : null}
{data && assignableRoles.length > 0 ? (
<UserRoleAssignDialog
open={assignOpen}
onOpenChange={setAssignOpen}
userId={data.id}
userName={data.name}
userEmail={data.email}
allRoles={assignableRoles}
currentRoleNames={currentRoleNames}
/>
) : null}
</DetailPageShell>
);
}
/**
* 详情内容区(基本信息 + 角色展示 + admin 警告卡)。
*/
function UserDetailBody({ user }: { user: User }): React.ReactElement {
const t = useTranslations("admin.users.detail");
const roleList =
user.roles && user.roles.length > 0 ? user.roles : [user.role];
const isAdmin = roleList.includes("admin");
return (
<>
{isAdmin ? (
<Alert className="border-warning/50 bg-warning/10 text-warning">
<ShieldAlert className="size-4" />
<AlertDescription>
<strong className="font-medium">{t("adminWarningTitle")}</strong>
{t("adminWarning")}
</AlertDescription>
</Alert>
) : null}
<DetailSection title={t("sectionBasic")}>
<DetailField label={t("fieldName")} value={user.name} />
<DetailField label={t("fieldEmail")} value={user.email} />
<DetailField label={t("fieldPhone")} value={user.phone ?? "-"} />
<DetailField
label={t("fieldStatus")}
value={<StatusBadge status={user.status} />}
/>
<DetailField label={t("fieldUserType")} value={user.userType ?? "-"} />
<DetailField
label={t("fieldCreatedAt")}
value={formatUserDate(user.createdAt)}
/>
<DetailField
label={t("fieldUpdatedAt")}
value={formatUserDate(user.updatedAt)}
/>
</DetailSection>
<DetailSection title={t("sectionRoles")}>
<div className="flex flex-wrap gap-2">
{roleList.map((r) => (
<RoleBadge key={r} role={r} />
))}
</div>
</DetailSection>
</>
);
}
/**
* 主角色徽章(按角色色阶展示)。
*/
function RoleBadge({ role }: { role: string }): React.ReactElement {
const label = formatUserRole(role);
const cls = userRoleToBadgeClass(role);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
/**
* 状态徽章(按状态色阶展示)。
*/
function StatusBadge({ status }: { status: string }): React.ReactElement {
const label = formatUserStatus(status);
const cls = userStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
/**
* 用户编辑对话框name/email/phone/role/status
*/
function UserEditDialog({
open,
onOpenChange,
user,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User;
}): React.ReactElement | null {
const t = useTranslations("admin.users.detail");
const tCommon = useTranslations("common");
const router = useRouter();
const { run: updateUser, loading: updating } = useUpdateUser();
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
const [phone, setPhone] = useState(user.phone ?? "");
const [role, setRole] = useState(user.role);
const [status, setStatus] = useState(user.status);
// 打开时同步当前用户数据
useEffect(() => {
if (open) {
setName(user.name);
setEmail(user.email);
setPhone(user.phone ?? "");
setRole(user.role);
setStatus(user.status);
}
}, [open, user]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
const input: UserUpdateInput = {
name: name.trim(),
email: email.trim(),
phone: phone.trim(),
role,
status,
};
try {
await updateUser(user.id, input);
notify.success(t("success"));
onOpenChange(false);
router.refresh();
} catch (err) {
notify.error(tCommon("error.operationFailed", { message: String(err) }));
}
};
if (!open) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="size-5" />
{t("editDialogTitle")}
</DialogTitle>
<DialogDescription>{t("editDialogDescription")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="user-name">{t("labelName")}</Label>
<Input
id="user-name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={updating}
required
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-email">{t("labelEmail")}</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={updating}
required
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-phone">{t("labelPhone")}</Label>
<Input
id="user-phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
disabled={updating}
maxLength={20}
placeholder="13800000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">{t("labelRole")}</Label>
<Select
id="user-role"
value={role}
onValueChange={setRole}
options={USER_ROLE_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
disabled={updating}
aria-label={t("labelRole")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-status">{t("labelStatus")}</Label>
<Select
id="user-status"
value={status}
onValueChange={setStatus}
options={USER_STATUS_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
disabled={updating}
aria-label={t("labelStatus")}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={updating}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={updating}>
{updating ? t("submitting") : t("submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -33,11 +33,13 @@ import {
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import {
activeToBadgeClass,
extractPermissions,
@@ -278,12 +280,20 @@ function ViewportEditDialog({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-base">{t("editTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Dialog
open={true}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-base">{t("editTitle")}</DialogTitle>
<DialogDescription className="sr-only">
{t("editTitle")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FormField label={t("colName")}>
<Input
type="text"
@@ -331,17 +341,17 @@ function ViewportEditDialog({
className="size-4 cursor-pointer rounded border border-input"
/>
</FormField>
<div className="flex justify-end gap-2 border-t pt-4">
<DialogFooter className="border-t pt-4">
<Button variant="outline" onClick={onClose}>
{tCommon("button.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{t("saveButton")}
</Button>
</div>
</CardContent>
</Card>
</div>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
/**
* 撰写消息页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P5 B3 末)
*
* 数据契约:
* - messageRecipients ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - sendMessage ❌ schema 无此 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty无收件人
*
* 回复模式URL 携带 ?reply=<id> 时,将原消息 id 作为 parent_message_id 传入。
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
*/
import { Loader2, Mail } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useMessageRecipients, useSendMessage } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Select, type SelectOption } from "@/shared/components/ui/select";
import { Textarea } from "@/shared/components/ui/textarea";
import {
FormPageShell,
FormPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 撰写消息客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function MessageComposeClient(): React.ReactElement {
const t = useTranslations("shared.messages.compose");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const replyId = searchParams.get("reply") ?? "";
// @contract-pendingMSW 兜底
const { data: recipients, loading, error } = useMessageRecipients();
const { run: sendMessage, loading: sending } = useSendMessage();
const [receiverId, setReceiverId] = useState("");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const recipientList = recipients ?? [];
const isError = !!error;
const isEmpty = !loading && !isError && recipientList.length === 0;
const errorNode = isError ? (
<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("mswNotice")}</p>
</div>
) : undefined;
const options: SelectOption[] = recipientList.map((r) => ({
value: r.id,
label: r.role ? `${r.name} (${r.role})` : r.name,
}));
const handleSubmit = async (): Promise<void> => {
if (!receiverId) {
notify.error(t("selectRecipient"));
return;
}
if (!subject.trim()) {
notify.error(t("subjectRequired"));
return;
}
if (!body.trim()) {
notify.error(t("bodyRequired"));
return;
}
try {
await sendMessage({
receiver_id: receiverId,
subject: subject.trim(),
body: body.trim(),
parent_message_id: replyId || undefined,
});
notify.success(t("sendSuccess"));
router.push("/shell/messages");
} catch (err) {
notify.error(t("sendError"));
console.error("[shared.messages.compose] sendMessage failed:", err);
}
};
return (
<FormPageShell
title={t("title")}
description={t("description")}
icon={<Mail className="size-6" />}
backHref="/shell/messages"
loading={loading}
loadingNode={<FormPageSkeleton />}
errorSummary={errorNode}
onSubmit={isError || isEmpty ? undefined : handleSubmit}
submitting={sending}
submitLabel={t("send")}
cancelLabel={t("cancel")}
>
{isEmpty ? (
<EmptyState icon={Mail} title={t("noRecipients")} />
) : (
<>
<div className="grid gap-2">
<Label htmlFor="message-recipient">{t("fieldTo")}</Label>
<Select
id="message-recipient"
value={receiverId}
onValueChange={setReceiverId}
options={options}
placeholder={t("toPlaceholder")}
disabled={sending}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="message-subject">{t("fieldSubject")}</Label>
<Input
id="message-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("subjectPlaceholder")}
maxLength={255}
disabled={sending}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="message-body">{t("fieldBody")}</Label>
<Textarea
id="message-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t("bodyPlaceholder")}
className="min-h-[200px]"
disabled={sending}
/>
</div>
{sending ? (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t("sending")}
</p>
) : null}
</>
)}
</FormPageShell>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
/**
* 消息详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P5 B3 末)
*
* 数据契约:
* - studentMessageDetail ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty未找到
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
*/
import { Mail, Reply, Star } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useStudentMessageDetail } from "@/lib/api";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
/**
* 消息详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function MessageDetailClient(): React.ReactElement {
const t = useTranslations("shared.messages.detail");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const id = params?.id ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useStudentMessageDetail(id);
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("mswNotice")}</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState icon={Mail} title={t("notFound")} />
) : undefined;
const actions = data ? (
<Button asChild variant="outline" size="sm">
<Link href={`/shell/messages/compose?reply=${data.id}`}>
<Reply className="size-4" />
{t("reply")}
</Link>
</Button>
) : undefined;
return (
<DetailPageShell
title={data ? data.subject : t("title")}
icon={<Mail className="size-6" />}
backHref="/shell/messages"
actions={actions}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <MessageDetailBody data={data} /> : null}
</DetailPageShell>
);
}
/**
* 消息详情内容区(元信息字段 + 正文)。
*/
function MessageDetailBody({
data,
}: {
data: NonNullable<ReturnType<typeof useStudentMessageDetail>["data"]>;
}): React.ReactElement {
const t = useTranslations("shared.messages.detail");
return (
<>
<DetailSection title={t("title")}>
<DetailField label={t("fieldFrom")} value={data.from} />
<DetailField label={t("fieldTo")} value={data.to} />
<DetailField label={t("fieldSubject")} value={data.subject} />
<DetailField label={t("fieldDate")} value={formatDate(data.date)} />
<DetailField
label={t("fieldStatus")}
value={<MessageStatusBadge status={data.status} />}
/>
<DetailField
label={t("fieldStarred")}
value={
data.is_starred ? (
<span className="inline-flex items-center gap-1">
<Star className="size-4 fill-current text-amber-500" />
{t("starredYes")}
</span>
) : (
t("starredNo")
)
}
/>
</DetailSection>
<DetailSection title={t("fieldBody")}>
<div className="whitespace-pre-wrap py-2 text-sm leading-relaxed">
{data.body}
</div>
</DetailSection>
</>
);
}
/**
* 消息状态徽章(按已读/未读展示)。
*/
function MessageStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const t = useTranslations("shared.messages.detail");
if (status === "read") {
return <Badge variant="secondary">{t("statusRead")}</Badge>;
}
if (status === "unread") {
return <Badge variant="default">{t("statusUnread")}</Badge>;
}
return <Badge variant="outline">{status}</Badge>;
}
/** 格式化 ISO 日期字符串为本地化展示。 */
function formatDate(isoDate: string): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN");
}

View File

@@ -0,0 +1,194 @@
"use client";
/**
* 群发消息页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P5 B3 末)
*
* 数据契约:
* - messageRecipients ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - sendMessage ❌ schema 无此 Mutation 类型 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
*
* 与单聊 compose 的区别:
* - 收件人为多选Checkbox 列表),逐条调用 sendMessage 投递
* - 不支持 parent_message_id群发不作为某条消息的回复
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty无收件人
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P5 / §11.3 / §11.4
*/
import { Loader2, Mail } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useMessageRecipients, useSendMessage } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import {
FormPageShell,
FormPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/**
* 群发消息客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function MessageGroupComposeClient(): React.ReactElement {
const t = useTranslations("shared.messages.groupCompose");
const tCommon = useTranslations("common");
const router = useRouter();
// @contract-pendingMSW 兜底
const { data: recipients, loading, error } = useMessageRecipients();
const { run: sendMessage, loading: sending } = useSendMessage();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const recipientList = recipients ?? [];
const isError = !!error;
const isEmpty = !loading && !isError && recipientList.length === 0;
const errorNode = isError ? (
<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("mswNotice")}</p>
</div>
) : undefined;
const toggleRecipient = (id: string): void => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleSubmit = async (): Promise<void> => {
if (selectedIds.length === 0) {
notify.error(t("selectAtLeastOne"));
return;
}
if (!subject.trim()) {
notify.error(t("subjectRequired"));
return;
}
if (!body.trim()) {
notify.error(t("bodyRequired"));
return;
}
const trimmedSubject = subject.trim();
const trimmedBody = body.trim();
let successCount = 0;
let lastError: unknown = null;
try {
for (const rid of selectedIds) {
try {
await sendMessage({
receiver_id: rid,
subject: trimmedSubject,
body: trimmedBody,
});
successCount += 1;
} catch (err) {
lastError = err;
}
}
if (successCount === 0) {
notify.error(t("sendError"));
console.error(
"[shared.messages.groupCompose] all sendMessages failed:",
lastError,
);
return;
}
notify.success(t("sendSuccess", { count: successCount }));
router.push("/shell/messages");
} catch (err) {
notify.error(t("sendError"));
console.error("[shared.messages.groupCompose] sendMessage failed:", err);
}
};
return (
<FormPageShell
title={t("title")}
description={t("description")}
icon={<Mail className="size-6" />}
backHref="/shell/messages"
loading={loading}
loadingNode={<FormPageSkeleton />}
errorSummary={errorNode}
onSubmit={isError || isEmpty ? undefined : handleSubmit}
submitting={sending}
submitLabel={t("send")}
cancelLabel={t("cancel")}
>
{isEmpty ? (
<EmptyState icon={Mail} title={t("noRecipients")} />
) : (
<>
<div className="grid gap-2">
<Label>{t("fieldTo")}</Label>
<p className="text-xs text-muted-foreground">
{t("selectedCount", { count: selectedIds.length })}
</p>
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border p-3">
{recipientList.map((r) => (
<label
key={r.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={selectedIds.includes(r.id)}
onChange={() => toggleRecipient(r.id)}
disabled={sending}
className="size-4 rounded border-input accent-primary"
/>
<span className="flex-1">{r.name}</span>
{r.role ? (
<span className="text-xs text-muted-foreground">
{r.role}
</span>
) : null}
</label>
))}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group-message-subject">{t("fieldSubject")}</Label>
<Input
id="group-message-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("subjectPlaceholder")}
maxLength={255}
disabled={sending}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="group-message-body">{t("fieldBody")}</Label>
<Textarea
id="group-message-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t("bodyPlaceholder")}
className="min-h-[200px]"
disabled={sending}
/>
</div>
{sending ? (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{t("sending")}
</p>
) : null}
</>
)}
</FormPageShell>
);
}

View File

@@ -0,0 +1,329 @@
"use client";
/**
* 个人资料页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 B1 共享页)
*
* 数据契约(@contract-pending 全 MSW
* - userProfile❌ schema 无此根字段 → MSW 兜底
* - 契约工单docs/architecture/issues/contracts/iam_contract.md#user-profile
*
* 布局:
* - 顶部"编辑资料"按钮 → /shell/settings
* - 双卡片grid sm:grid-cols-2小屏 1 列):
* - 左卡片:个人信息(姓名/性别/年龄/电话/地址)
* - 右卡片:账户信息(邮箱/角色 Badge/注册时间/onboarded 时间)
* - 角色专属概览面板:
* - 学生:班级/年级/班主任/学业概况
* - 教师:教授课程/班级数/学生数
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ empty资料空态
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 B1 / §11.3 / §11.4
*/
import { Pencil, UserCircle } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useUserProfile, type UserProfileDetail } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
DetailField,
DetailPageShell,
DetailPageSkeleton,
DetailSection,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import { cn } from "@/shared/lib/utils";
/**
* 个人资料客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ProfileClient(): React.ReactElement {
const t = useTranslations("shared.profile");
const tCommon = useTranslations("common");
// @contract-pendingMSW 兜底
const { data, loading, error, refetch } = useUserProfile();
const handleRefetch = async (): Promise<void> => {
try {
await refetch();
} catch (err) {
notify.error(tCommon("error.loadFailed", { message: String(err) }));
}
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => void handleRefetch()}
>
{tCommon("button.retry")}
</Button>
</div>
) : undefined;
return (
<DetailPageShell
title={t("title")}
description={t("description")}
icon={<UserCircle className="size-6" />}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
actions={
<Button asChild size="sm" variant="outline">
<Link href="/shell/settings" className="gap-2">
<Pencil className="size-4" />
{t("editProfile")}
</Link>
</Button>
}
>
{data ? <ProfileContent profile={data} /> : null}
</DetailPageShell>
);
}
/**
* 个人资料主体内容:双卡片 + 角色专属概览。
*/
function ProfileContent({
profile,
}: {
profile: UserProfileDetail;
}): React.ReactElement {
const t = useTranslations("shared.profile");
return (
<div className="space-y-6">
{/* 双卡片:左=个人信息 / 右=账户信息(小屏 1 列) */}
<div className="grid gap-6 sm:grid-cols-2">
<DetailSection title={t("sectionPersonal")}>
<DetailField label={t("fieldName")} value={profile.name || "--"} />
<DetailField
label={t("fieldGender")}
value={renderGender(profile.gender, t)}
/>
<DetailField
label={t("fieldAge")}
value={profile.age ? String(profile.age) : "--"}
/>
<DetailField label={t("fieldPhone")} value={profile.phone || "--"} />
<DetailField
label={t("fieldAddress")}
value={profile.address || "--"}
/>
</DetailSection>
<DetailSection title={t("sectionAccount")}>
<DetailField label={t("fieldEmail")} value={profile.email || "--"} />
<DetailField
label={t("fieldRole")}
value={<RoleBadge role={profile.role} />}
/>
<DetailField
label={t("fieldCreatedAt")}
value={formatDateTime(profile.createdAt)}
/>
<DetailField
label={t("fieldOnboardedAt")}
value={formatDateTime(profile.onboardedAt)}
/>
</DetailSection>
</div>
{/* 角色专属概览面板 */}
{profile.role === "student" && profile.student ? (
<StudentOverviewSection overview={profile.student} />
) : null}
{profile.role === "teacher" && profile.teacher ? (
<TeacherOverviewSection overview={profile.teacher} />
) : null}
{profile.role !== "student" && profile.role !== "teacher" ? (
<DetailSection title={t("sectionRoleOverview")}>
<p className="text-sm text-muted-foreground">{t("noRoleOverview")}</p>
</DetailSection>
) : null}
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
);
}
/**
* 学生角色概览面板。
*/
function StudentOverviewSection({
overview,
}: {
overview: NonNullable<UserProfileDetail["student"]>;
}): React.ReactElement {
const t = useTranslations("shared.profile");
return (
<DetailSection title={t("sectionRoleOverview")}>
<DetailField
label={t("fieldClassName")}
value={overview.className || "--"}
/>
<DetailField label={t("fieldGrade")} value={overview.grade || "--"} />
<DetailField
label={t("fieldHeadTeacher")}
value={overview.headTeacher || "--"}
/>
<DetailField
label={t("fieldAvgScore")}
value={
typeof overview.avgScore === "number"
? overview.avgScore.toFixed(1)
: "--"
}
/>
<DetailField
label={t("fieldClassRank")}
value={
overview.totalStudents > 0
? t("rankValue", {
rank: overview.classRank,
total: overview.totalStudents,
})
: "--"
}
/>
</DetailSection>
);
}
/**
* 教师角色概览面板。
*/
function TeacherOverviewSection({
overview,
}: {
overview: NonNullable<UserProfileDetail["teacher"]>;
}): React.ReactElement {
const t = useTranslations("shared.profile");
return (
<DetailSection title={t("sectionRoleOverview")}>
<DetailField
label={t("fieldClassCount")}
value={String(overview.classCount)}
/>
<DetailField
label={t("fieldStudentCount")}
value={String(overview.studentCount)}
/>
<div className="space-y-1 py-2">
<dt className="w-32 shrink-0 text-sm text-muted-foreground">
{t("fieldCourses")}
</dt>
<dd className="flex flex-1 flex-wrap gap-2">
{overview.courses.length === 0 ? (
<span className="text-sm">--</span>
) : (
overview.courses.map((c) => (
<span
key={c.id}
className="inline-flex h-6 items-center rounded-full bg-muted px-2 text-xs font-medium"
>
{c.name}
</span>
))
)}
</dd>
</div>
</DetailSection>
);
}
/**
* 角色徽章admin/teacher/student/parent
*/
function RoleBadge({ role }: { role: string }): React.ReactElement {
const t = useTranslations("shared.profile");
const label = roleToLabel(role, t);
const cls = roleToBadgeClass(role);
return (
<span
className={cn(
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium",
cls,
)}
>
{label}
</span>
);
}
/** 角色枚举 → i18n 标签。未知角色回退原始值。 */
function roleToLabel(
role: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (role) {
case "admin":
return t("roleAdmin");
case "teacher":
return t("roleTeacher");
case "student":
return t("roleStudent");
case "parent":
return t("roleParent");
default:
return role;
}
}
/** 角色 → Tailwind 徽章类名。 */
function roleToBadgeClass(role: string): string {
switch (role) {
case "admin":
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
case "teacher":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "student":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "parent":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
default:
return "bg-muted text-muted-foreground";
}
}
/** 性别枚举 → i18n 标签。 */
function renderGender(
gender: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (gender) {
case "male":
return t("genderMale");
case "female":
return t("genderFemale");
case "other":
return t("genderOther");
default:
return gender || "--";
}
}
/** ISO 时间字符串 → 本地化日期时间展示。 */
function formatDateTime(iso: string): string {
if (!iso) return "--";
const d = new Date(iso);
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",
});
}

View File

@@ -0,0 +1,686 @@
"use client";
/**
* 学生仪表盘首页 - 客户端组件ARCHITECTURE.md §7.1 / §10 P1-2 / P3
*
* 数据契约studentDashboard 真实聚合查询data-ana subgraph
* P3 扩展字段enrolled_classes_count / grades / upcoming_assignments /
* today_schedule尚未在 subgraph SDL 中定义,由 MSW 兜底
* @contract-pending
*
* 三态规范§11.3 DoD
* - loadingStatCard 骨架 + DashboardSection 骨架
* - error局部降级 Card + 文案
* - emptyEmptyState 组件
*
* 关联ARCHITECTURE.md §5.5 / §7.1 / §10 P1-2 / §10 P3 / §11.3 / §11.4
*/
import {
BookOpen,
CalendarDays,
CheckCircle,
GraduationCap,
PenTool,
TriangleAlert,
Trophy,
TrendingUp,
} from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import type { StudentDashboard } from "@/lib/api";
import { useStudentDashboard } from "@/lib/api";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
import {
GradeTrendChart,
type GradeTrendPoint,
} from "@/shared/components/charts";
/** 根据当前小时返回问候语 i18n 键。 */
function getGreetingKey(
hour: number,
):
| "greetingMorning"
| "greetingNoon"
| "greetingAfternoon"
| "greetingEvening"
| "greetingNight" {
if (hour >= 5 && hour < 11) return "greetingMorning";
if (hour >= 11 && hour < 13) return "greetingNoon";
if (hour >= 13 && hour < 18) return "greetingAfternoon";
if (hour >= 18 && hour < 23) return "greetingEvening";
return "greetingNight";
}
/** 判断作业是否已逾期(未提交且截止时间已过)。 */
function isAssignmentOverdue(
dueAt: string | null | undefined,
status: string,
): boolean {
if (!dueAt || status === "graded") return false;
const due = new Date(dueAt).getTime();
if (Number.isNaN(due)) return false;
return due < Date.now();
}
/** 判断作业是否在 N 天内到期(未逾期且未提交)。 */
function isAssignmentDueSoon(
dueAt: string | null | undefined,
status: string,
days: number,
): boolean {
if (!dueAt || status === "graded") return false;
const due = new Date(dueAt).getTime();
if (Number.isNaN(due)) return false;
const diff = due - Date.now();
return diff >= 0 && diff <= days * 24 * 60 * 60 * 1000;
}
/** 将 "HH:MM" 时间字符串转为分钟数。 */
function parseTimeToMinutes(time: string | null | undefined): number | null {
if (!time) return null;
const match = /^(\d{1,2}):(\d{2})$/.exec(time);
if (!match) return null;
return parseInt(match[1] ?? "0", 10) * 60 + parseInt(match[2] ?? "0", 10);
}
/** 学生仪表盘客户端主体。 */
export function StudentDashboardClient(): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
const tCommon = useTranslations("common");
const { data, loading, error } = useStudentDashboard();
if (loading) {
return <StudentDashboardSkeleton />;
}
if (error || !data) {
return (
<DashboardShell title={t("title")} description={t("description")}>
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{tCommon("error.loadFailed", { message: String(error) })}
</CardContent>
</Card>
</DashboardShell>
);
}
return <StudentDashboardBody data={data} />;
}
/** 仪表盘主体(已有数据)。 */
function StudentDashboardBody({
data,
}: {
data: StudentDashboard;
}): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
const ranking = data.grades?.ranking ?? null;
const upcomingAssignments = data.upcoming_assignments ?? [];
const todaySchedule = data.today_schedule ?? [];
const gradesTrend = data.grades?.trend ?? [];
const gradesRecent = data.grades?.recent ?? [];
const now = new Date();
const greeting = t(getGreetingKey(now.getHours()));
const studentName = data.student_name ?? "";
const todayDate = now.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
});
return (
<DashboardShell
title={t("greetingWithName", { greeting, name: studentName })}
description={todayDate}
stats={
<StudentStatsGrid
enrolledClassesCount={data.enrolled_classes_count ?? 0}
gradedCount={data.graded_count ?? 0}
dueSoonCount={data.due_soon_count ?? 0}
overdueCount={data.overdue_count ?? 0}
ranking={ranking}
/>
}
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<DashboardSection
title={t("sectionGradeTrend")}
variant="list"
ariaLabel={t("sectionGradeTrend")}
actions={
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-primary" />
{t("legendScore")}
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-muted-foreground" />
{t("legendClassAvg")}
</span>
</div>
}
>
<GradeTrendChart
data={gradesTrend.map((item): GradeTrendPoint => ({
label: item.assignment_title ?? "",
score: item.percentage ?? 0,
classAvg: item.class_avg ?? null,
}))}
emptyMessage={t("emptyGradeTrend")}
/>
</DashboardSection>
<DashboardSection
title={t("sectionUpcomingAssignments")}
variant="list"
ariaLabel={t("sectionUpcomingAssignments")}
actions={
<Button asChild variant="outline" size="sm">
<Link href="/shell/student/homework">{t("actionViewAll")}</Link>
</Button>
}
>
<UpcomingAssignmentsList assignments={upcomingAssignments} />
</DashboardSection>
<DashboardSection
title={t("sectionRecentGrades")}
variant="table"
ariaLabel={t("sectionRecentGrades")}
actions={
<Button asChild variant="outline" size="sm">
<Link href="/shell/student/grades">{t("actionViewAll")}</Link>
</Button>
}
>
<RecentGradesTable trend={gradesTrend} recent={gradesRecent} />
</DashboardSection>
</div>
<div className="space-y-6">
<DashboardSection
title={t("sectionTodaySchedule")}
variant="list"
ariaLabel={t("sectionTodaySchedule")}
actions={
<Button asChild variant="outline" size="sm">
<Link href="/shell/student/schedule">
{t("actionViewSchedule")}
</Link>
</Button>
}
>
<TodayScheduleList items={todaySchedule} />
</DashboardSection>
</div>
</div>
</DashboardShell>
);
}
/** 6 StatCard 网格(对齐 CICD student-stats-grid.tsx。 */
function StudentStatsGrid({
enrolledClassesCount,
gradedCount,
dueSoonCount,
overdueCount,
ranking,
}: {
enrolledClassesCount: number;
gradedCount: number;
dueSoonCount: number;
overdueCount: number;
ranking: {
rank?: number | null;
class_size?: number | null;
percentage?: number | null;
} | null;
}): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
return (
<>
<StatCard
title={t("statEnrolledClasses")}
value={String(enrolledClassesCount)}
description={t("descActiveEnrollments")}
icon={BookOpen}
href="/shell/student/courses"
valueClassName="text-emerald-500 tabular-nums"
/>
<StatCard
title={t("statAvgScore")}
value={ranking ? `${Math.round(ranking.percentage ?? 0)}%` : "--"}
description={
ranking ? t("descOverallPerformance") : t("descNoGradesYet")
}
icon={TrendingUp}
href="/shell/student/grades"
valueClassName={ranking ? "text-blue-500 tabular-nums" : "tabular-nums"}
/>
<StatCard
title={t("statClassRank")}
value={
ranking
? `${ranking.rank ?? "--"}/${ranking.class_size ?? "--"}`
: "--"
}
description={ranking ? t("descCurrentPosition") : t("descNoRankingYet")}
icon={Trophy}
href="/shell/student/grades"
valueClassName={
ranking ? "text-purple-500 tabular-nums" : "tabular-nums"
}
/>
<StatCard
title={t("statGraded")}
value={String(gradedCount)}
description={t("descCompletedAssignments")}
icon={CheckCircle}
href="/shell/student/homework"
valueClassName="text-green-500 tabular-nums"
/>
<StatCard
title={t("statDueSoon")}
value={String(dueSoonCount)}
description={t("descNext7Days")}
icon={PenTool}
href="/shell/student/homework"
highlight={dueSoonCount > 0}
valueClassName={
dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"
}
/>
<StatCard
title={t("statOverdue")}
value={String(overdueCount)}
description={t("descNeedsAttention")}
icon={TriangleAlert}
href="/shell/student/homework"
highlight={overdueCount > 0}
valueClassName={
overdueCount > 0 ? "text-red-500 tabular-nums" : "tabular-nums"
}
/>
</>
);
}
/** 待办作业列表。 */
function UpcomingAssignmentsList({
assignments,
}: {
assignments: Array<{
id?: string | null;
title?: string | null;
subject_name?: string | null;
due_at?: string | null;
progress_status?: string | null;
latest_score?: number | null;
}>;
}): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
if (assignments.length === 0) {
return (
<EmptyState
icon={PenTool}
title={t("emptyNoAssignments")}
description={t("emptyNoAssignmentsDesc")}
className="border-none"
/>
);
}
return (
<div className="overflow-x-auto rounded-md border bg-card">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colTitle")}
</th>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colSubject")}
</th>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colStatus")}
</th>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colDue")}
</th>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colScore")}
</th>
<th className="p-3 text-right text-xs font-medium uppercase text-muted-foreground">
{t("colAction")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{assignments.map((item) => {
const status = item.progress_status ?? "not_started";
const href = `/shell/student/homework/${item.id ?? ""}`;
const urgency = isAssignmentOverdue(item.due_at, status)
? "overdue"
: isAssignmentDueSoon(item.due_at, status, 3)
? "urgent"
: "normal";
const dueClassName =
urgency === "overdue"
? "p-3 font-medium text-red-500 tabular-nums"
: urgency === "urgent"
? "p-3 font-medium text-orange-500 tabular-nums"
: "p-3 text-muted-foreground tabular-nums";
return (
<tr key={item.id ?? ""} className="h-12 hover:bg-muted/30">
<td className="p-3 font-medium">
<Link href={href} className="truncate hover:underline">
{item.title ?? "--"}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{item.subject_name ?? "--"}
</td>
<td className="p-3">
<div className="flex flex-wrap items-center gap-1.5">
<ProgressStatusBadge status={status} />
{urgency === "overdue" ? (
<Badge className="bg-red-500 text-white">
{t("badgeLate")}
</Badge>
) : urgency === "urgent" ? (
<Badge className="bg-orange-500 text-white">
{t("labelUrgent")}
</Badge>
) : null}
</div>
</td>
<td className={dueClassName}>{item.due_at ?? "--"}</td>
<td className="p-3 tabular-nums">
{item.latest_score ?? "--"}
</td>
<td className="p-3 text-right">
<Button
asChild
size="sm"
variant="outline"
className="h-7 text-xs"
>
<Link href={href}>
{getProgressActionKey(status) === "continue"
? t("actionContinue")
: getProgressActionKey(status) === "review"
? t("actionReview")
: t("actionStart")}
</Link>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** 进度状态徽章。 */
function ProgressStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
if (status === "graded") {
return (
<Badge className="bg-emerald-500 text-white">{t("badgeGraded")}</Badge>
);
}
if (status === "in_progress") {
return <Badge variant="secondary">{t("badgeSubmitted")}</Badge>;
}
return <Badge variant="outline">{t("badgeNotStarted")}</Badge>;
}
/** 根据进度状态返回操作类型键。 */
function getProgressActionKey(status: string): "start" | "continue" | "review" {
if (status === "graded") return "review";
if (status === "in_progress") return "continue";
return "start";
}
/** 近期成绩表格。 */
function RecentGradesTable({
trend,
recent,
}: {
trend: Array<{
assignment_id?: string | null;
assignment_title?: string | null;
score?: number | null;
max_score?: number | null;
percentage?: number | null;
submitted_at?: string | null;
}>;
recent: Array<{
assignment_id?: string | null;
assignment_title?: string | null;
score?: number | null;
max_score?: number | null;
percentage?: number | null;
submitted_at?: string | null;
}>;
}): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
const source = recent.length > 0 ? recent : trend;
if (source.length === 0) {
return (
<EmptyState
icon={GraduationCap}
title={t("emptyNoGradedWork")}
description={t("emptyNoGradedWorkDesc")}
className="border-none"
/>
);
}
return (
<div className="overflow-x-auto rounded-md border bg-card">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colAssignment")}
</th>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colScore")}
</th>
<th className="p-3 text-left text-xs font-medium uppercase text-muted-foreground">
{t("colWhen")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{source.map((item, idx) => {
const key = item.assignment_id ?? `grade-${idx}`;
return (
<tr key={key} className="h-12 hover:bg-muted/30">
<td className="p-3 font-medium">
<Link
href={`/shell/student/homework/${item.assignment_id ?? ""}`}
className="hover:underline"
>
{item.assignment_title ?? "--"}
</Link>
</td>
<td className="p-3 tabular-nums">
{item.score ?? "--"}/{item.max_score ?? "--"}{" "}
<span className="text-muted-foreground">
({Math.round(item.percentage ?? 0)}%)
</span>
</td>
<td className="p-3 text-muted-foreground">
{item.submitted_at ?? "--"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** 今日课表列表。 */
function TodayScheduleList({
items,
}: {
items: Array<{
id?: string | null;
class_id?: string | null;
class_name?: string | null;
course?: string | null;
start_time?: string | null;
end_time?: string | null;
location?: string | null;
}>;
}): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
if (items.length === 0) {
return (
<EmptyState
icon={CalendarDays}
title={t("emptyNoClassesToday")}
description={t("emptyNoClassesTodayDesc")}
className="border-none"
/>
);
}
const nowMinutes = parseTimeToMinutes(new Date().toTimeString().slice(0, 5));
let nextClassIdx = -1;
const currentClassIdx = items.findIndex((item) => {
const start = parseTimeToMinutes(item.start_time);
const end = parseTimeToMinutes(item.end_time);
return (
start !== null &&
end !== null &&
nowMinutes !== null &&
nowMinutes >= start &&
nowMinutes < end
);
});
if (currentClassIdx === -1) {
items.forEach((item, idx) => {
const start = parseTimeToMinutes(item.start_time);
if (
nextClassIdx === -1 &&
start !== null &&
nowMinutes !== null &&
start > nowMinutes
) {
nextClassIdx = idx;
}
});
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="size-4 text-muted-foreground" />
{t("sectionTodaySchedule")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.map((item, idx) => {
const key = item.id ?? `sch-${idx}`;
const isCurrent = idx === currentClassIdx;
const isNext = idx === nextClassIdx;
const itemClassName = isCurrent
? "flex items-center justify-between rounded-md border border-emerald-500 bg-emerald-50 p-3 dark:bg-emerald-950/30"
: isNext
? "flex items-center justify-between rounded-md border border-blue-500 bg-blue-50 p-3 dark:bg-blue-950/30"
: "flex items-center justify-between rounded-md border p-3";
return (
<div key={key} className={itemClassName}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{item.course ?? "--"}</span>
{isCurrent ? (
<Badge className="bg-emerald-500 text-white">
{t("badgeInProgress")}
</Badge>
) : isNext ? (
<Badge variant="secondary">{t("badgeUpNext")}</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{item.class_name ?? "--"} · {t("colLocation")}
{item.location ?? "--"}
</div>
</div>
<div className="text-right text-xs">
<div className="font-mono text-foreground">
{item.start_time ?? "--"} - {item.end_time ?? "--"}
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
);
}
/** 仪表盘骨架屏。 */
function StudentDashboardSkeleton(): React.ReactElement {
const t = useTranslations("studentDomain.dashboard.home");
return (
<DashboardShell title={t("title")} description={t("description")}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<StatCard key={i} title="" value="" isLoading />
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<DashboardSection
title={t("sectionUpcomingAssignments")}
variant="list"
>
<div />
</DashboardSection>
<DashboardSection title={t("sectionRecentGrades")} variant="table">
<div />
</DashboardSection>
</div>
<DashboardSection title={t("sectionTodaySchedule")} variant="list">
<div />
</DashboardSection>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,645 @@
"use client";
/**
* 学生自我诊断报告页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.2 / §10 P3
*
* 数据契约studentDiagnostic ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/data-ana_contract.md#diagnostic-student-detail
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级 + notify.error()
* - emptyEmptyState 组件
*
* 页面结构DetailPageShell
* - 4 个概览卡片StatCard学生姓名 / 总体掌握率 / 优势数 / 弱势数
* - 知识点掌握度雷达图(纯 SVG 自研,无 recharts 依赖)
* - 优势知识点列表 Card含掌握率 Badge
* - 弱势知识点列表 Card含掌握率 Badge + 练习跳转按钮)
* - 最新诊断报告 Card状态徽章 / 置信度 Badge + Tooltip / 报告期 / 得分 / 摘要 / 推荐建议)
* - 历史报告 Card已发布报告列表
* - SectionErrorBoundary 包裹各区块
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.2 / §10 P3 / §11.3 / §11.4
*/
import {
Activity,
ArrowRight,
CheckCircle,
FileText,
GraduationCap,
TrendingDown,
TrendingUp,
} from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import type {
StudentSelfDiagnostic,
StudentSelfDiagnosticReport,
StudentMasteryPoint,
} from "@/lib/api";
import { useStudentSelfDiagnostic } from "@/lib/api";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import {
DetailPageShell,
DetailPageSkeleton,
} from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
/** 学生自我诊断客户端主体。需由 server page 包裹在 <Suspense> 中。 */
export function StudentSelfDiagnosticClient(): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
const tCommon = useTranslations("common");
// @contract-pending MSW 兜底
const { data, loading, error } = useStudentSelfDiagnostic();
useEffect(() => {
if (error) {
notify.error(tCommon("error.loadFailed", { message: String(error) }));
}
}, [error, tCommon]);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState icon={GraduationCap} title={t("emptyDiagnostic")} />
);
return (
<DetailPageShell
title={t("title")}
description={t("description")}
icon={<Activity className="size-6" />}
backHref="/shell/student"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={!loading && !error && !data ? emptyNode : undefined}
>
{data ? <DiagnosticBody data={data} /> : null}
</DetailPageShell>
);
}
/** 诊断主体(已有数据)。 */
function DiagnosticBody({
data,
}: {
data: StudentSelfDiagnostic;
}): React.ReactElement {
const { summary, masteryPoints, reports } = data;
const strengths = masteryPoints.filter((p) => p.isStrength);
const weaknesses = masteryPoints.filter((p) => !p.isStrength);
const latestReport = reports[0] ?? null;
const historyReports = reports.slice(1);
return (
<div className="space-y-6">
<SectionErrorBoundary title={undefined}>
<OverviewStats summary={summary} />
</SectionErrorBoundary>
<SectionErrorBoundary>
<MasteryRadarCard points={masteryPoints} />
</SectionErrorBoundary>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionErrorBoundary>
<StrengthListCard points={strengths} />
</SectionErrorBoundary>
<SectionErrorBoundary>
<WeaknessListCard points={weaknesses} />
</SectionErrorBoundary>
</div>
{latestReport ? (
<SectionErrorBoundary>
<LatestReportCard report={latestReport} />
</SectionErrorBoundary>
) : null}
<SectionErrorBoundary>
<HistoryReportsCard reports={historyReports} />
</SectionErrorBoundary>
</div>
);
}
/** 4 个概览卡片。 */
function OverviewStats({
summary,
}: {
summary: StudentSelfDiagnostic["summary"];
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
const masteryPct = `${Math.round(summary.overallMastery * 100)}%`;
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("statStudentName")}
value={summary.studentName}
icon={GraduationCap}
/>
<StatCard
title={t("statOverallMastery")}
value={masteryPct}
icon={TrendingUp}
valueClassName="text-emerald-500 tabular-nums"
/>
<StatCard
title={t("statStrengthCount")}
value={String(summary.strengthCount)}
icon={CheckCircle}
valueClassName="text-emerald-500 tabular-nums"
/>
<StatCard
title={t("statWeaknessCount")}
value={String(summary.weaknessCount)}
icon={TrendingDown}
valueClassName="text-amber-500 tabular-nums"
/>
</div>
);
}
/** 知识点掌握度雷达图 Card纯 SVG 自研)。 */
function MasteryRadarCard({
points,
}: {
points: StudentMasteryPoint[];
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
if (points.length < 3) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="size-4 text-muted-foreground" />
{t("sectionMasteryRadar")}
</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={Activity}
title={t("emptyDiagnostic")}
className="border-none"
/>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="size-4 text-muted-foreground" />
{t("sectionMasteryRadar")}
</CardTitle>
</CardHeader>
<CardContent>
<MasteryRadarChart points={points} />
</CardContent>
</Card>
);
}
/** 纯 SVG 雷达图(无 recharts 依赖,参考 portal-shell TrendChart 自研模式)。 */
function MasteryRadarChart({
points,
}: {
points: StudentMasteryPoint[];
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
const size = 400;
const cx = size / 2;
const cy = size / 2;
const R = 130;
const labelOffset = 28;
const n = points.length;
const angleFor = (i: number): number => -Math.PI / 2 + (i * 2 * Math.PI) / n;
const pointAt = (i: number, frac: number): { x: number; y: number } => ({
x: cx + frac * R * Math.cos(angleFor(i)),
y: cy + frac * R * Math.sin(angleFor(i)),
});
const gridLevels = [0.25, 0.5, 0.75, 1];
const toPolyPoints = (frac: number): string =>
Array.from({ length: n }, (_, i) => {
const p = pointAt(i, frac);
return `${p.x.toFixed(1)},${p.y.toFixed(1)}`;
}).join(" ");
const dataPolyPoints = points
.map((p, i) => {
const mastery = Math.max(0, Math.min(1, p.masteryRate));
const pt = pointAt(i, mastery);
return `${pt.x.toFixed(1)},${pt.y.toFixed(1)}`;
})
.join(" ");
return (
<div className="flex justify-center py-2">
<svg
viewBox={`0 0 ${size} ${size}`}
className="w-full max-w-md"
role="img"
aria-label={t("sectionMasteryRadar")}
>
{/* 网格环 */}
{gridLevels.map((level) => (
<polygon
key={level}
points={toPolyPoints(level)}
fill="none"
className="stroke-muted-foreground/20"
strokeWidth={1}
/>
))}
{/* 轴线 */}
{Array.from({ length: n }, (_, i) => {
const end = pointAt(i, 1);
return (
<line
key={i}
x1={cx}
y1={cy}
x2={end.x.toFixed(1)}
y2={end.y.toFixed(1)}
className="stroke-muted-foreground/20"
strokeWidth={1}
/>
);
})}
{/* 数据多边形 */}
<polygon
points={dataPolyPoints}
className="fill-primary/20 stroke-primary"
strokeWidth={2}
/>
{/* 数据点 */}
{points.map((p, i) => {
const mastery = Math.max(0, Math.min(1, p.masteryRate));
const pt = pointAt(i, mastery);
return (
<circle
key={i}
cx={pt.x.toFixed(1)}
cy={pt.y.toFixed(1)}
r={3}
className="fill-primary"
/>
);
})}
{/* 轴标签 */}
{points.map((p, i) => {
const angle = angleFor(i);
const lx = cx + (R + labelOffset) * Math.cos(angle);
const ly = cy + (R + labelOffset) * Math.sin(angle);
return (
<text
key={i}
x={lx.toFixed(1)}
y={ly.toFixed(1)}
textAnchor="middle"
dominantBaseline="middle"
fontSize={11}
className="fill-muted-foreground"
>
{p.name}
</text>
);
})}
</svg>
</div>
);
}
/** 优势知识点列表 Card。 */
function StrengthListCard({
points,
}: {
points: StudentMasteryPoint[];
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle className="size-4 text-emerald-500" />
{t("sectionStrengths")}
</CardTitle>
</CardHeader>
<CardContent>
{points.length === 0 ? (
<EmptyState
icon={CheckCircle}
title={t("emptyStrengths")}
className="border-none"
/>
) : (
<ul className="space-y-2">
{points.map((p) => (
<li
key={p.name}
className="flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm font-medium">{p.name}</span>
<Badge
variant="outline"
className={masteryToBadgeClass(p.masteryRate)}
>
{formatMastery(p.masteryRate)}
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
/** 弱势知识点列表 Card含练习跳转按钮。 */
function WeaknessListCard({
points,
}: {
points: StudentMasteryPoint[];
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingDown className="size-4 text-amber-500" />
{t("sectionWeakness")}
</CardTitle>
</CardHeader>
<CardContent>
{points.length === 0 ? (
<EmptyState
icon={TrendingDown}
title={t("emptyWeaknessPoints")}
className="border-none"
/>
) : (
<ul className="space-y-2">
{points.map((p) => (
<li
key={p.name}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{p.name}</span>
<Badge
variant="outline"
className={masteryToBadgeClass(p.masteryRate)}
>
{formatMastery(p.masteryRate)}
</Badge>
</div>
<Button asChild variant="outline" size="sm" className="h-7">
<Link href="/shell/student/practice">
{t("practiceNow")}
<ArrowRight className="size-3" />
</Link>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
/** 最新诊断报告 Card。 */
function LatestReportCard({
report,
}: {
report: StudentSelfDiagnosticReport;
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-muted-foreground" />
{t("sectionLatestReport")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className={reportStatusToBadgeClass(report.status)}
>
{reportStatusToLabel(report.status, t)}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="cursor-help">
{t("confidenceLabel")}{formatConfidence(report.confidence)}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">{t("confidenceTooltip")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-md border p-3">
<p className="text-xs text-muted-foreground">{t("fieldPeriod")}</p>
<p className="mt-1 text-sm font-medium">{report.period}</p>
</div>
<div className="rounded-md border p-3">
<p className="text-xs text-muted-foreground">{t("fieldScore")}</p>
<p className="mt-1 text-sm font-medium tabular-nums">
{report.score}
</p>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("fieldSummary")}</p>
<p className="mt-1 text-sm">{report.summary}</p>
</div>
{report.recommendations.length > 0 ? (
<div>
<p className="text-xs text-muted-foreground">
{t("fieldRecommendations")}
</p>
<ul className="mt-2 space-y-2">
{report.recommendations.map((rec, idx) => (
<li
key={idx}
className="flex items-start gap-2 rounded-md border bg-muted/30 p-3 text-sm"
>
<span className="mt-0.5 text-primary"></span>
<span>{rec}</span>
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
);
}
/** 历史报告 Card。 */
function HistoryReportsCard({
reports,
}: {
reports: StudentSelfDiagnosticReport[];
}): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-muted-foreground" />
{t("sectionHistoryReports")}
</CardTitle>
</CardHeader>
<CardContent>
{reports.length === 0 ? (
<EmptyState
icon={FileText}
title={t("emptyReports")}
className="border-none"
/>
) : (
<ul className="space-y-3">
{reports.map((report) => (
<li
key={report.id}
className="rounded-lg border bg-background p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ReportStatusBadge status={report.status} />
<span className="text-sm font-medium">{report.period}</span>
</div>
<span className="text-sm tabular-nums text-muted-foreground">
{t("fieldScore")}{report.score}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{report.summary}
</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
/** 格式化 0-1 的掌握度为百分比字符串。 */
function formatMastery(mastery: number): string {
if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) return "--";
return `${(mastery * 100).toFixed(0)}%`;
}
/** 格式化 0-1 的置信度为百分比字符串。 */
function formatConfidence(confidence: number): string {
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
return "--";
return `${(confidence * 100).toFixed(0)}%`;
}
/** 根据掌握度返回 Tailwind 徽章语义类名。 */
function masteryToBadgeClass(mastery: number): string {
if (!Number.isFinite(mastery) || mastery < 0 || mastery > 1) {
return "text-muted-foreground";
}
if (mastery >= 0.8) return "text-emerald-600";
if (mastery >= 0.6) return "text-amber-600";
return "text-destructive";
}
/** 根据报告状态返回 Tailwind 徽章语义类名。 */
function reportStatusToBadgeClass(status: string): string {
switch (status) {
case "published":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "generated":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "draft":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
default:
return "bg-muted text-muted-foreground";
}
}
/** 报告状态徽章(含 i18n 文案 + 语义化颜色)。 */
function ReportStatusBadge({ status }: { status: string }): React.ReactElement {
const t = useTranslations("studentDomain.diagnostic");
const label = reportStatusToLabel(status, t);
return (
<Badge variant="outline" className={reportStatusToBadgeClass(status)}>
{label}
</Badge>
);
}
/** 根据报告状态返回 i18n 文案。 */
function reportStatusToLabel(
status: string,
t: (key: string) => string,
): string {
switch (status) {
case "published":
return t("badgePublished");
case "generated":
return t("badgeGenerated");
case "draft":
return t("badgeDraft");
case "archived":
return t("badgeArchived");
default:
return status;
}
}

View File

@@ -0,0 +1,323 @@
"use client";
/**
* 错题详情对话框(学生端)
* 用原生轻量模态替代 shadcn Dialog。
* AI 分析区块 @contract-pending ai-analysis待 AI 服务接入后调用真实 API。
*/
import {
BookOpen,
Calendar,
GraduationCap,
Hash,
Lightbulb,
Loader2,
NotebookPen,
RefreshCw,
Sparkles,
Target,
TrendingUp,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useStartPracticeSession, type StudentErrorBookItem } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Separator } from "@/shared/components/ui/separator";
export interface ErrorBookDetailDialogProps {
item: StudentErrorBookItem | null;
onOpenChange: (open: boolean) => void;
onMarkMastered: (id: string) => Promise<void>;
marking: boolean;
}
export function ErrorBookDetailDialog({
item,
onOpenChange,
onMarkMastered,
marking,
}: ErrorBookDetailDialogProps): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const router = useRouter();
const open = item !== null;
const [practicing, setPracticing] = useState(false);
const { run: startPractice } = useStartPracticeSession();
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
if (!open || !item) return <></>;
const handleMarkMastered = async (): Promise<void> => {
await onMarkMastered(item.id);
onOpenChange(false);
};
const handleVariantPractice = async (): Promise<void> => {
setPracticing(true);
try {
// @contract-pending: kpId derived from error book item id, replace with real kpId after AI service integration
const kpId = "variant:" + item.id;
const sessionId = await startPractice([kpId]);
notify.success(t("practiceStarted"));
router.push("/shell/student/practice/" + sessionId);
onOpenChange(false);
} catch (err) {
notify.error(t("practiceStartError"));
console.error("[student.error-book] startVariantPractice failed:", err);
} finally {
setPracticing(false);
}
};
const handleEditNote = (): void => {
// @contract-pending: note editing pending backend contract
notify.info(t("noteEditPending"));
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="error-book-detail-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border bg-card shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between border-b p-6 pb-4">
<div className="flex-1">
<h2
id="error-book-detail-title"
className="flex items-center gap-2 text-lg font-semibold"
>
<BookOpen className="size-5" />
{t("detailTitle")}
</h2>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{item.subject ? (
<Badge variant="outline">{item.subject}</Badge>
) : null}
{item.difficulty ? (
<DifficultyBadge difficulty={item.difficulty} />
) : null}
{(item.errorTags ?? []).map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
aria-label={t("close")}
>
&#10005;
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-5">
<section className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
<InfoItem
icon={<Hash className="size-4" />}
label={t("colErrorCount")}
value={String(item.errorCount)}
/>
<InfoItem
icon={<RefreshCw className="size-4" />}
label={t("reviewCount")}
value={String(item.reviewCount ?? 0)}
/>
<InfoItem
icon={<TrendingUp className="size-4" />}
label={t("masteryLevel")}
value={formatMastery(item.masteryLevel)}
/>
</section>
<Separator />
<section>
<h3 className="mb-2 text-sm font-medium">{t("colQuestion")}</h3>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<pre className="whitespace-pre-wrap break-words font-sans">
{item.questionDetail?.content ?? item.question}
</pre>
</div>
</section>
{item.questionDetail?.answer ? (
<section>
<h3 className="mb-2 flex items-center gap-1 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<Target className="size-4" />
{t("correctAnswer")}
</h3>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{item.questionDetail.answer}
</pre>
</div>
</section>
) : null}
{item.note ? (
<section>
<h3 className="mb-2 flex items-center gap-1 text-sm font-medium">
<NotebookPen className="size-4" />
{t("note")}
</h3>
<div className="rounded-md border bg-background p-3 text-sm">
<pre className="whitespace-pre-wrap break-words font-sans">
{item.note}
</pre>
</div>
</section>
) : null}
{/* @contract-pending ai-analysis: placeholder, replace with real AI service call after integration */}
<section className="rounded-md border border-primary/20 bg-primary/5 p-4">
<h3 className="mb-2 flex items-center gap-1 text-sm font-medium">
<Sparkles className="size-4 text-primary" />
{t("aiAnalysisTitle")}
</h3>
<div className="space-y-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1">
<Lightbulb className="size-3" />
{t("aiErrorCategory")}: {item.errorTags?.[0] ?? "--"}
</p>
<p className="flex items-center gap-1">
<GraduationCap className="size-3" />
{t("aiKnowledgePoint")}: {item.subject}
</p>
<p className="flex items-center gap-1">
<TrendingUp className="size-3" />
{t("aiLearningPath")}: {t("aiLearningPathDesc")}
</p>
</div>
</section>
<section className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{t("createdAt")}: {formatDate(item.createdAt)}
</span>
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{t("colLastErrorAt")}: {formatDate(item.lastErrorAt)}
</span>
{item.nextReviewAt ? (
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{t("nextReviewAt")}: {formatDate(item.nextReviewAt)}
</span>
) : null}
</section>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t p-4">
<Button variant="outline" size="sm" onClick={handleEditNote} disabled>
<NotebookPen className="size-4" />
{t("editNote")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleVariantPractice()}
disabled={practicing}
>
{practicing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Sparkles className="size-4" />
)}
{t("variantPractice")}
</Button>
<Button
size="sm"
onClick={() => void handleMarkMastered()}
disabled={marking || item.status === "mastered"}
>
{marking ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Target className="size-4" />
)}
{t("markMastered")}
</Button>
</div>
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}): React.ReactElement {
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-medium">{value || "--"}</p>
</div>
</div>
);
}
function DifficultyBadge({
difficulty,
}: {
difficulty: "easy" | "medium" | "hard";
}): React.ReactElement {
const t = useTranslations("studentDomain.errorBook");
const labelKey =
difficulty === "easy"
? "difficultyEasy"
: difficulty === "medium"
? "difficultyMedium"
: "difficultyHard";
const cls =
difficulty === "easy"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: difficulty === "medium"
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
: "bg-destructive/10 text-destructive";
return (
<span
className={
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium " +
cls
}
>
{t(labelKey)}
</span>
);
}
function formatMastery(level?: number): string {
if (level === undefined || level === null) return "--";
return level + " / 5";
}
function formatDate(iso?: string): string {
if (!iso) return "--";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN");
}

View File

@@ -0,0 +1,248 @@
"use client";
/**
* 成长档案卡ARCHITECTURE.md §7.3 / §9.1 / §10 P2
*
* 纯 SVG 多折线图,展示跨学年/学期的各学科成绩变化。
* 每个学科一条折线 + 图例,数据点标注分数。
*
* 数据契约StudentGrowthArchivePoint[]@contract-pendingMSW 兜底)
* 设计令牌stroke-primary / stroke-blue-500 / stroke-emerald-500 / stroke-amber-500
* Tailwind 调色板,非 hex 字面量,符合 §3.10 设计令牌规范)
* 响应式viewBox + preserveAspectRatio小屏自适应宽度
*/
import { LineChart } from "lucide-react";
import { useTranslations } from "next-intl";
import type { StudentGrowthArchivePoint } from "@/lib/api";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
interface GrowthArchiveCardProps {
points: StudentGrowthArchivePoint[] | undefined;
}
/** 多学科折线颜色序列Tailwind 调色板,循环使用)。 */
const SUBJECT_STROKE_CLASSES = [
"stroke-primary",
"stroke-blue-500",
"stroke-emerald-500",
"stroke-amber-500",
"stroke-purple-500",
] as const;
/** 多学科数据点/图例 fill 颜色序列(与 STROKE 对齐)。 */
const SUBJECT_FILL_CLASSES = [
"fill-primary",
"fill-blue-500",
"fill-emerald-500",
"fill-amber-500",
"fill-purple-500",
] as const;
/** 成长档案卡:无数据时降级为 EmptyState有数据时渲染 SVG 多折线图。 */
export function GrowthArchiveCard({
points,
}: GrowthArchiveCardProps): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
if (!points || points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("sectionGrowthArchive")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-muted-foreground">
{t("growthEmptyTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("growthEmptyDescription")}
</p>
</div>
</CardContent>
</Card>
);
}
const subjects = collectSubjects(points);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<LineChart className="size-4 text-muted-foreground" />
{t("sectionGrowthArchive")}
</CardTitle>
</CardHeader>
<CardContent>
<GrowthArchiveSvg points={points} subjects={subjects} />
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="font-medium">{t("growthArchiveSubjectLabel")}:</span>
{subjects.map((subj, i) => {
const fillClass =
SUBJECT_FILL_CLASSES[i % SUBJECT_FILL_CLASSES.length]!;
return (
<span key={subj} className="inline-flex items-center gap-1">
<span
className={`inline-block size-2 rounded-full ${fillClass}`}
/>
{subj}
</span>
);
})}
</div>
</CardContent>
</Card>
);
}
/** 成长档案 SVG 多折线图(每学科一条线)。 */
function GrowthArchiveSvg({
points,
subjects,
}: {
points: StudentGrowthArchivePoint[];
subjects: string[];
}): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
const width = 480;
const height = 220;
const padX = 40;
const padY = 24;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const n = points.length;
const yMin = 0;
const yMax = 100;
const yRange = yMax - yMin;
const xStep = n > 1 ? innerW / (n - 1) : 0;
const toX = (i: number): number => padX + i * xStep;
const toY = (score: number): number =>
padY + innerH - ((score - yMin) / yRange) * innerH;
const yTicks = [0, 50, 100];
const series = subjects.map((subj, idx) => {
const strokeClass =
SUBJECT_STROKE_CLASSES[idx % SUBJECT_STROKE_CLASSES.length]!;
const fillClass = SUBJECT_FILL_CLASSES[idx % SUBJECT_FILL_CLASSES.length]!;
const coords = points
.map((p, i) => {
const scoreEntry = p.subjectScores.find((s) => s.subject === subj);
if (!scoreEntry) return null;
return {
x: toX(i),
y: toY(scoreEntry.score),
score: scoreEntry.score,
i,
};
})
.filter(
(c): c is { x: number; y: number; score: number; i: number } =>
c !== null,
);
const pathD = coords
.map((c, i) => `${i === 0 ? "M" : "L"} ${c.x} ${c.y}`)
.join(" ");
return { subj, strokeClass, fillClass, coords, pathD };
});
return (
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-56 w-full"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={t("sectionGrowthArchive")}
>
{/* 网格线 + Y 轴得分标签 */}
{yTicks.map((tick) => (
<g key={tick}>
<line
x1={padX}
x2={width - padX}
y1={toY(tick)}
y2={toY(tick)}
className="stroke-muted"
strokeWidth={1}
strokeDasharray="2 4"
/>
<text
x={padX - 8}
y={toY(tick) + 4}
className="fill-muted-foreground"
fontSize={10}
textAnchor="end"
>
{tick}
</text>
</g>
))}
{/* 各学科折线 + 数据点 */}
{series.map((s) => (
<g key={s.subj}>
<path
d={s.pathD}
fill="none"
className={s.strokeClass}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{s.coords.map((c) => (
<g key={`${s.subj}-${c.i}`}>
<circle cx={c.x} cy={c.y} r={3} className={s.fillClass} />
<text
x={c.x}
y={c.y - 8}
className={s.fillClass}
fontSize={10}
textAnchor="middle"
>
{c.score}
</text>
</g>
))}
</g>
))}
{/* X 轴学期标签 */}
{points.map((p, i) => (
<text
key={`${p.period}-${i}`}
x={toX(i)}
y={height - 4}
className="fill-muted-foreground"
fontSize={10}
textAnchor="middle"
>
{p.period}
</text>
))}
</svg>
</div>
);
}
/** 从所有数据点中收集学科列表(保持出现顺序,去重)。 */
function collectSubjects(points: StudentGrowthArchivePoint[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const p of points) {
for (const s of p.subjectScores) {
if (!seen.has(s.subject)) {
seen.add(s.subject);
result.push(s.subject);
}
}
}
return result;
}

View File

@@ -0,0 +1,202 @@
"use client";
/**
* 排名趋势卡ARCHITECTURE.md §7.3 / §9.1 / §10 P2
*
* 纯 SVG 折线图,展示最近若干次考试的班级排名变化。
* Y 轴反向(排名 1 在顶部,排名越大越靠下),数据点标注排名数字。
*
* 数据契约StudentRankingTrendPoint[]@contract-pendingMSW 兜底)
* 设计令牌stroke-primary / fill-primary / fill-muted-foreground / stroke-muted
* 响应式viewBox + preserveAspectRatio小屏自适应宽度
*/
import { TrendingUp } from "lucide-react";
import { useTranslations } from "next-intl";
import type { StudentRankingTrendPoint } from "@/lib/api";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
interface RankingTrendCardProps {
points: StudentRankingTrendPoint[] | undefined;
}
/** 排名趋势卡:无数据时降级为 EmptyState有数据时渲染 SVG 折线图。 */
export function RankingTrendCard({
points,
}: RankingTrendCardProps): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
if (!points || points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("sectionRankingTrend")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-muted-foreground">
{t("rankingEmptyTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("rankingEmptyDescription")}
</p>
</div>
</CardContent>
</Card>
);
}
const lastPoint = points[points.length - 1];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="size-4 text-muted-foreground" />
{t("sectionRankingTrend")}
</CardTitle>
</CardHeader>
<CardContent>
<RankingTrendSvg points={points} />
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<span className="inline-block size-2 rounded-full bg-primary" />
{t("rankingLegend")}
</span>
{lastPoint ? (
<span>
{t("rankingTotalStudents", { total: lastPoint.totalStudents })}
</span>
) : null}
</div>
</CardContent>
</Card>
);
}
/** 排名趋势 SVG 折线图Y 轴反向:排名 1 在顶部)。 */
function RankingTrendSvg({
points,
}: {
points: StudentRankingTrendPoint[];
}): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
const width = 480;
const height = 200;
const padX = 40;
const padY = 24;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const n = points.length;
const ranks = points.map((p) => p.rank);
const maxRank = Math.max(...ranks, 1);
const minRank = 1;
// Y 轴范围至少为 1避免所有排名相同时除零
const yRange = Math.max(maxRank - minRank, 1);
const xStep = n > 1 ? innerW / (n - 1) : 0;
const toX = (i: number): number => padX + i * xStep;
// 反向 Yrank 1 → 顶部padYmaxRank → 底部padY + innerH
const toY = (rank: number): number =>
padY + ((rank - minRank) / yRange) * innerH;
const pathD =
n === 1
? `M ${toX(0)} ${toY(points[0]!.rank)}`
: points
.map((p, i) => `${i === 0 ? "M" : "L"} ${toX(i)} ${toY(p.rank)}`)
.join(" ");
// Y 轴刻度:顶部(#1、中部、底部#maxRank
const yTicks = [minRank, Math.round((minRank + maxRank) / 2), maxRank];
return (
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-48 w-full"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={t("sectionRankingTrend")}
>
{/* 网格线 + Y 轴排名标签(#1 在顶部 = 最佳) */}
{yTicks.map((rank) => (
<g key={rank}>
<line
x1={padX}
x2={width - padX}
y1={toY(rank)}
y2={toY(rank)}
className="stroke-muted"
strokeWidth={1}
strokeDasharray="2 4"
/>
<text
x={padX - 8}
y={toY(rank) + 4}
className="fill-muted-foreground"
fontSize={10}
textAnchor="end"
>
#{rank}
</text>
</g>
))}
{/* 趋势折线 */}
<path
d={pathD}
fill="none"
className="stroke-primary"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* 数据点 + 排名标签 + X 轴日期 */}
{points.map((p, i) => (
<g key={`${p.period}-${i}`}>
<circle
cx={toX(i)}
cy={toY(p.rank)}
r={4}
className="fill-primary"
/>
<text
x={toX(i)}
y={toY(p.rank) - 10}
className="fill-primary font-medium"
fontSize={11}
textAnchor="middle"
>
#{p.rank}
</text>
<text
x={toX(i)}
y={height - 4}
className="fill-muted-foreground"
fontSize={10}
textAnchor="middle"
>
{formatPeriod(p.period)}
</text>
</g>
))}
</svg>
</div>
);
}
/** 格式化周期标签ISO 日期 → MM-DD否则原样返回。 */
function formatPeriod(period: string): string {
const match = period.match(/^\d{4}-(\d{2})-(\d{2})$/);
if (match) {
return `${match[1]}-${match[2]}`;
}
return period;
}

View File

@@ -0,0 +1,189 @@
"use client";
/**
* 班级分布卡ARCHITECTURE.md §7.3 / §9.1 / §10 P2
*
* 纯 SVG 柱状图,展示当前考试/学科的班级分数段分布。
* 高亮当前学生所在分数段primary 色),其余段用 muted 色。
*
* 数据契约StudentScoreDistribution@contract-pendingMSW 兜底)
* 设计令牌fill-primary / fill-muted / stroke-muted-foreground
* 响应式viewBox + preserveAspectRatio小屏自适应宽度
*/
import { BarChart3 } from "lucide-react";
import { useTranslations } from "next-intl";
import type { StudentScoreDistribution } from "@/lib/api";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
interface ScoreDistributionCardProps {
distribution: StudentScoreDistribution | undefined;
}
/** 班级分布卡:无数据时降级为 EmptyState有数据时渲染 SVG 柱状图。 */
export function ScoreDistributionCard({
distribution,
}: ScoreDistributionCardProps): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
if (!distribution || distribution.ranges.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("sectionDistribution")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-muted-foreground">
{t("distributionEmptyTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("distributionEmptyDescription")}
</p>
</div>
</CardContent>
</Card>
);
}
const studentBucket = distribution.ranges.find((r) => r.isStudentIn);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="size-4 text-muted-foreground" />
{t("sectionDistribution")}
</CardTitle>
</CardHeader>
<CardContent>
<ScoreDistributionSvg distribution={distribution} />
{studentBucket ? (
<p className="mt-3 text-xs text-muted-foreground">
{t("distributionStudentPosition", {
bucket: studentBucket.range,
rank: distribution.studentRank,
})}
</p>
) : null}
</CardContent>
</Card>
);
}
/** 班级分布 SVG 柱状图(高亮当前学生所在分数段)。 */
function ScoreDistributionSvg({
distribution,
}: {
distribution: StudentScoreDistribution;
}): React.ReactElement {
const t = useTranslations("studentDomain.grades.list");
const width = 480;
const height = 200;
const padX = 40;
const padY = 24;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const ranges = distribution.ranges;
const n = ranges.length;
const maxCount = Math.max(...ranges.map((r) => r.count), 1);
const barGap = 12;
const barWidth = n > 0 ? (innerW - barGap * (n - 1)) / n : 0;
const toX = (i: number): number => padX + i * (barWidth + barGap);
const barTop = (count: number): number =>
padY + innerH - (count / maxCount) * innerH;
// Y 轴刻度0、中部、最大值
const yTicks = [0, Math.round(maxCount / 2), maxCount];
return (
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-48 w-full"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={t("sectionDistribution")}
>
{/* 网格线 + Y 轴人数标签 */}
{yTicks.map((tick) => {
const y = padY + innerH - (tick / maxCount) * innerH;
return (
<g key={tick}>
<line
x1={padX}
x2={width - padX}
y1={y}
y2={y}
className="stroke-muted"
strokeWidth={1}
strokeDasharray="2 4"
/>
<text
x={padX - 8}
y={y + 4}
className="fill-muted-foreground"
fontSize={10}
textAnchor="end"
>
{tick}
</text>
</g>
);
})}
{/* 柱状条 + 人数标签 + X 轴分数段标签 */}
{ranges.map((r, i) => {
const x = toX(i);
const top = barTop(r.count);
const barH = padY + innerH - top;
return (
<g key={r.range}>
<rect
x={x}
y={top}
width={barWidth}
height={barH}
className={r.isStudentIn ? "fill-primary" : "fill-muted"}
rx={2}
/>
<text
x={x + barWidth / 2}
y={top - 6}
className={
r.isStudentIn
? "fill-primary font-medium"
: "fill-muted-foreground"
}
fontSize={11}
textAnchor="middle"
>
{t("distributionBucketCount", { count: r.count })}
</text>
<text
x={x + barWidth / 2}
y={height - 4}
className={
r.isStudentIn
? "fill-primary font-medium"
: "fill-muted-foreground"
}
fontSize={10}
textAnchor="middle"
>
{r.range}
</text>
</g>
);
})}
</svg>
</div>
);
}

View File

@@ -290,7 +290,7 @@ function TrendCard({ points }: { points: TrendPoint[] }): React.ReactElement {
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[160px] flex-col items-center justify-center gap-2 text-center">
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-muted-foreground">
{t("trendEmptyTitle")}
</p>

View File

@@ -0,0 +1,88 @@
/**
* 学生成绩报告卡 A4 打印样式ARCHITECTURE.md §7.3 / §10 P2
*
* 仅在 @media print 下生效,控制 A4 纸张布局:
* - 隐藏导航 / 侧边栏 / 顶栏 / 打印按钮等非打印元素
* - 强制黑白文本,保证打印可读性
* - A4 容器不被分页切割
*
* 关联report-card-client.tsx 中 .report-card-a4 容器
*/
@media print {
/* 隐藏非打印元素:侧边栏、顶栏、面包屑、打印按钮、切换器 */
[data-slot="sidebar"],
[data-slot="sidebar-trigger"],
[data-slot="topbar"],
[data-slot="page-header"],
.no-print,
.report-card-toolbar,
.report-card-period-selector {
display: none !important;
}
/* 重置页面边距,由 A4 容器 padding 控制 */
@page {
size: A4;
margin: 0;
}
html,
body {
margin: 0 !important;
padding: 0 !important;
background: #ffffff !important;
}
/* A4 容器:去除屏幕态阴影,撑满页面 */
.report-card-a4 {
box-shadow: none !important;
margin: 0 !important;
width: 210mm !important;
min-height: 297mm !important;
padding: 15mm !important;
background: #ffffff !important;
color: #000000 !important;
}
/* 强制黑色文本(打印友好) */
.report-card-a4,
.report-card-a4 * {
color: #000000 !important;
background: #ffffff !important;
border-color: #000000 !important;
box-shadow: none !important;
text-shadow: none !important;
}
/* 表格边框在打印下加粗为黑色细线 */
.report-card-a4 table {
border-collapse: collapse !important;
}
.report-card-a4 th,
.report-card-a4 td {
border: 1px solid #000000 !important;
}
/* 签名区虚线在打印下可见 */
.report-card-signature-line {
border-bottom: 1px dashed #000000 !important;
}
/* 避免行内分割:同学科行尽量保持在同一页 */
.report-card-a4 tr,
.report-card-a4 thead,
.report-card-a4 tfoot {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
/* 综合统计与评语区不跨页 */
.report-card-summary,
.report-card-comments,
.report-card-signatures {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
}

View File

@@ -0,0 +1,224 @@
"use client";
/**
* 学生请假 - 表单组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3
*
* 从 leave-client.tsx 抽取,单一职责:表单渲染 + 校验 + 提交。
* - 接收 defaultStudentId / defaultClassId 作为初始值(可选)
* - 通过 useStudentClasses 获取班级下拉数据
* - 自动写入 defaultClassId 或第一个活跃班级
* - 提交成功后调用 onSubmitted 回调(父组件刷新列表)
*
* 数据契约(@contract-pending 全 MSW
* - studentClasses / submitLeaveRequest❌ schema 无此字段 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
useStudentClasses,
useSubmitLeaveRequest,
type LeaveRequestInput,
} from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { notify } from "@/shared/lib/notify";
export interface LeaveRequestFormProps {
/** 默认学生 ID预留当前契约 studentId 由后端从上下文推断) */
defaultStudentId?: string;
/** 默认班级 ID优先于"第一个活跃班级"自动选择) */
defaultClassId?: string;
/** 提交成功回调(父组件用于刷新列表 / 重置分页) */
onSubmitted?: () => void;
}
/**
* 请假申请表单。受控组件,内部维护 5 个字段 + 校验态。
*/
export function LeaveRequestForm({
defaultStudentId: _defaultStudentId,
defaultClassId,
onSubmitted,
}: LeaveRequestFormProps): React.ReactElement {
const t = useTranslations("studentDomain.leave");
// @contract-pendingMSW 兜底(班级下拉数据源)
const { data: classes } = useStudentClasses();
// @contract-pendingMSW 兜底
const { run: submitLeave, loading: submitting } = useSubmitLeaveRequest();
const [classId, setClassId] = useState(defaultClassId ?? "");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [type, setType] = useState("");
const [reason, setReason] = useState("");
const [formError, setFormError] = useState<string | null>(null);
// 默认选择 defaultClassId 或第一个活跃班级
useEffect(() => {
if (!classId && classes) {
if (defaultClassId) {
setClassId(defaultClassId);
return;
}
const firstActive = classes.find((c) => c.isActive);
if (firstActive) setClassId(firstActive.id);
}
}, [classes, classId, defaultClassId]);
const hasActiveClass = (classes ?? []).some((c) => c.isActive);
const handleFormSubmit = (): void => {
setFormError(null);
if (!classId) {
setFormError(t("validation.selectClass"));
return;
}
if (!startDate) {
setFormError(t("validation.selectStartDate"));
return;
}
if (!endDate) {
setFormError(t("validation.selectEndDate"));
return;
}
if (!type) {
setFormError(t("validation.selectType"));
return;
}
if (!reason.trim()) {
setFormError(t("validation.fillReason"));
return;
}
if (endDate < startDate) {
setFormError(t("validation.endDateBeforeStart"));
return;
}
const input: LeaveRequestInput = {
classId,
startDate,
endDate,
reason: reason.trim(),
type,
};
void (async (): Promise<void> => {
try {
await submitLeave(input);
notify.success(t("submitSuccess"));
// 重置表单(保留班级选择),通知父组件刷新
setStartDate("");
setEndDate("");
setType("");
setReason("");
onSubmitted?.();
} catch {
notify.error(t("submitError"));
}
})();
};
return (
<div className="space-y-4">
{!hasActiveClass ? (
<p className="text-sm text-muted-foreground">{t("noActiveClass")}</p>
) : null}
<FormField label={t("fieldClassId")} required>
<select
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
disabled={!hasActiveClass}
>
<option value="">{t("fieldClassId")}</option>
{(classes ?? []).map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</FormField>
<FormField label={t("fieldStartDate")} required>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
<FormField label={t("fieldEndDate")} required>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</FormField>
<FormField label={t("fieldType")} required>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">{t("fieldType")}</option>
<option value="personal">{t("typePersonal")}</option>
<option value="sick">{t("typeSick")}</option>
<option value="other">{t("typeOther")}</option>
</select>
</FormField>
<FormField label={t("fieldReason")} required>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</FormField>
{formError ? (
<p className="text-sm text-destructive">{formError}</p>
) : null}
<Button
onClick={handleFormSubmit}
disabled={submitting || !hasActiveClass}
>
{t("submit")}
</Button>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
);
}
/**
* 表单字段容器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,269 @@
"use client";
/**
* 学生请假 - 历史列表组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P3
*
* 从 leave-client.tsx 抽取,单一职责:列表渲染 + 分页控件 + 空态。
* - 接收 emptyTitle / emptyDescription 控制空态文案
* - 显示分页信息(总数、当前页范围)
* - 上一页 / 下一页 / 页码跳转
*
* 数据契约(@contract-pending 全 MSW
* - studentLeave❌ schema 无此字段 → MSW 兜底
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P3 / §11.3 / §11.4
*/
import { CalendarClock, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl";
import type { StudentLeaveItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
export interface LeaveRequestListProps {
/** 当前页数据项 */
items: StudentLeaveItem[];
/** 总记录数 */
total: number;
/** 当前页码1-based */
page: number;
/** 每页大小 */
pageSize: number;
/** 加载态 */
loading?: boolean;
/** 翻页回调 */
onPageChange: (page: number) => void;
/** 空态标题(可选,默认走 i18n */
emptyTitle?: string;
/** 空态描述(可选,默认走 i18n */
emptyDescription?: string;
}
/**
* 请假历史列表 + 分页控件。
*/
export function LeaveRequestList({
items,
total,
page,
pageSize,
loading = false,
onPageChange,
emptyTitle,
emptyDescription,
}: LeaveRequestListProps): React.ReactElement {
const t = useTranslations("studentDomain.leave");
if (!loading && items.length === 0) {
return (
<EmptyState
icon={CalendarClock}
title={emptyTitle ?? t("emptyTitle")}
description={emptyDescription}
/>
);
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const startIndex = total === 0 ? 0 : (page - 1) * pageSize + 1;
const endIndex = Math.min(page * pageSize, total);
// 页码按钮:显示当前页 ± 2首尾固定
const pageNumbers = buildPageNumbers(page, totalPages);
return (
<div className="space-y-3">
{/* 范围信息 */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{t("paginationRange", {
start: startIndex,
end: endIndex,
total,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("paginationPage", { page, totalPages })}
</p>
</div>
{/* 表格 */}
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colStartDate")}</th>
<th className="p-3 text-left font-medium">{t("colEndDate")}</th>
<th className="p-3 text-left font-medium">{t("colType")}</th>
<th className="p-3 text-left font-medium">{t("colReason")}</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatDate(item.startDate)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatDate(item.endDate)}
</td>
<td className="p-3">{formatLeaveType(item.type, t)}</td>
<td className="p-3 text-xs text-muted-foreground">
{item.reason}
</td>
<td className="p-3">
<LeaveStatusBadge status={item.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页控件 */}
{totalPages > 1 ? (
<div className="flex items-center justify-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1 || loading}
aria-label={t("paginationPrev")}
>
<ChevronLeft className="size-4" />
</Button>
{pageNumbers.map((p, idx) =>
p === null ? (
<span
key={`gap-${idx}`}
className="px-2 text-xs text-muted-foreground"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(p)}
disabled={loading}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages || loading}
aria-label={t("paginationNext")}
>
<ChevronRight className="size-4" />
</Button>
</div>
) : null}
</div>
);
}
/**
* 请假状态徽章pending 琥珀 / approved 翡翠 / rejected 危险色)。
*/
function LeaveStatusBadge({ status }: { status: string }): React.ReactElement {
const t = useTranslations("studentDomain.leave");
const label = leaveStatusToLabel(status, t);
const cls = leaveStatusToBadgeClass(status);
return (
<span
className={cn(
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium",
cls,
)}
>
{label}
</span>
);
}
/** 将请假状态枚举值映射为 i18n 标签。未知状态回退为原始值。 */
function leaveStatusToLabel(
status: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (status) {
case "pending":
return t("statusPending");
case "approved":
return t("statusApproved");
case "rejected":
return t("statusRejected");
default:
return status;
}
}
/** 根据请假状态返回 Tailwind 徽章类名。 */
function leaveStatusToBadgeClass(status: string): string {
switch (status) {
case "pending":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "approved":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "rejected":
return "bg-destructive/10 text-destructive";
default:
return "bg-muted text-muted-foreground";
}
}
/** 将请假类型枚举值映射为 i18n 标签。未知类型回退为原始值。 */
function formatLeaveType(
type: string,
t: ReturnType<typeof useTranslations>,
): string {
switch (type) {
case "personal":
return t("typePersonal");
case "sick":
return t("typeSick");
case "other":
return t("typeOther");
default:
return type;
}
}
/** 格式化 ISO 日期字符串为本地化展示。 */
function formatDate(isoDate: string): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN");
}
/**
* 构造分页页码数组。返回 null 表示省略号占位。
* - 当前页 ± 2
* - 首尾固定显示
* - 中间用 null填充
*/
function buildPageNumbers(
current: number,
total: number,
): Array<number | null> {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: Array<number | null> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) pages.push(null);
for (let i = start; i <= end; i++) pages.push(i);
if (end < total - 1) pages.push(null);
pages.push(total);
return pages;
}

View File

@@ -0,0 +1,349 @@
"use client";
import { useState, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Bot, X, Sparkles, RotateCcw, ChevronLeft } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet";
import { AiChatPanel } from "./ai-chat-panel";
import { useAiClientOptional } from "../context/ai-client-provider";
import { useFloatingBall } from "../hooks/use-floating-ball";
/**
* 上下文感知规则
*
* 根据当前路由推断用户上下文,动态生成 systemPrompt 和 contextMessage。
*/
type AiContextConfig = {
systemPrompt: string;
contextMessage: string;
suggestedPrompts?: string[];
};
/**
* 全局 AI 助手悬浮球360 悬浮球风格)— 内部实现
*
* 特性:
* - 可拖拽移动,松手吸附到最近屏幕边缘
* - 拖到边缘自动半隐藏(只露出一小部分)
* - 鼠标悬停时恢复显示
* - 位置持久化到 localStorage
* - 点击打开侧边抽屉,内嵌 AiChatPanel
* - 上下文感知:根据当前路由自动推断用户场景
*
* 该组件被打包进独立 chunk由 ai-assistant-widget.tsx 通过 next/dynamic 以 ssr:false 懒加载),
* 以避免 AiChatPanel 依赖的 AI SDK / Markdown 渲染等重型依赖被打入首屏 chunk。
*
* 使用:在 dashboard layout 中引入即可全局生效。
* 需要 AiClientProvider 包裹(可选,未注入时按钮不显示)。
*/
export function AiAssistantWidgetInner(): React.ReactNode {
const t = useTranslations("ai");
const pathname = usePathname();
const aiClient = useAiClientOptional();
const [open, setOpen] = useState(false);
const [chatKey, setChatKey] = useState(0);
const handleBallClick = () => setOpen(true);
const ball = useFloatingBall(handleBallClick);
// 根据路由推断上下文
const contextConfig = useMemo<AiContextConfig>(() => {
return inferContextFromPath(pathname, t);
}, [pathname, t]);
// 如果未注入 AI 客户端服务,不显示悬浮按钮
if (!aiClient) {
return null;
}
const {
position,
hidden,
dragging,
hovered,
hiddenOffset,
handlers,
show,
resetPosition,
} = ball;
// 首次渲染时 position 为占位值(屏幕外),避免闪烁
const isReady = position.x < 9999;
return (
<>
{/* 悬浮球 */}
{isReady ? (
<button
type="button"
aria-label={hidden ? t("widget.show") : t("widget.open")}
className="fixed z-50 flex select-none items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-lg transition-[width,height,opacity,transform] duration-200 ease-out hover:shadow-xl hover:scale-105 touch-none"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: "56px",
height: "56px",
transform: `translateX(${hiddenOffset}px) ${dragging ? "scale(1.1)" : ""}`,
cursor: dragging ? "grabbing" : "grab",
opacity: hidden && !hovered ? 0.55 : 1,
transition: dragging
? "none"
: "transform 0.25s ease-out, opacity 0.25s ease-out",
}}
onPointerDown={handlers.onPointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={handlers.onPointerUp}
onPointerCancel={handlers.onPointerCancel}
onMouseEnter={handlers.onMouseEnter}
onMouseLeave={handlers.onMouseLeave}
onClick={(e) => {
// 拖动产生的 click 不触发打开
if (e.detail === 0) return;
}}
>
<Bot className="h-6 w-6 drop-shadow-sm" />
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500 ring-2 ring-background" />
</span>
{hidden ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-primary/30 opacity-0 transition-opacity hover:opacity-100">
<ChevronLeft
className="h-4 w-4"
style={{
transform:
position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</span>
) : null}
</button>
) : null}
{/* 半隐藏时的提示条 */}
{hidden && isReady ? (
<button
type="button"
aria-label={t("widget.show")}
className="fixed z-40 flex items-center justify-center rounded-full bg-primary/15 text-primary backdrop-blur-sm transition-opacity hover:bg-primary/25"
style={{
left: `${position.x + (hiddenOffset > 0 ? 0 : BALL_SIZE * 0.45)}px`,
top: `${position.y}px`,
width: "14px",
height: "56px",
cursor: "pointer",
}}
onClick={show}
>
<ChevronLeft
className="h-3 w-3"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</button>
) : null}
{/* 侧边抽屉 */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="w-full sm:max-w-lg overflow-hidden p-0 flex flex-col">
<SheetHeader className="px-5 py-4 border-b bg-gradient-to-r from-primary/5 to-transparent">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
<Bot className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-base font-semibold leading-tight">
{t("widget.title")}
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("widget.online")}
</span>
</div>
</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => {
try {
localStorage.removeItem("ai-chat-history");
} catch {
// ignore
}
setChatKey((k) => k + 1);
}}
aria-label={t("widget.newChat")}
title={t("widget.newChat")}
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={resetPosition}
aria-label={t("widget.resetPosition")}
title={t("widget.resetPosition")}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => setOpen(false)}
aria-label={t("widget.close")}
title={t("widget.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<AiChatPanel
key={chatKey}
systemPrompt={contextConfig.systemPrompt}
contextMessage={contextConfig.contextMessage}
suggestedPrompts={contextConfig.suggestedPrompts}
maxMessages={30}
variant="widget"
/>
</div>
</SheetContent>
</Sheet>
</>
);
}
const BALL_SIZE = 56;
/**
* 根据路由推断 AI 上下文
*/
function inferContextFromPath(
pathname: string,
t: ReturnType<typeof useTranslations>,
): AiContextConfig {
// 教师批改
if (pathname.includes("/teacher/homework/submissions")) {
return {
systemPrompt:
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
contextMessage: t("chat.contextMessage.teacherGrading"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.context.teacherGrading.0"),
t("chat.suggestedPrompts.context.teacherGrading.1"),
],
};
}
// 教师备课
if (pathname.includes("/teacher/lesson-plans")) {
return {
systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
contextMessage: t("chat.contextMessage.teacherLesson"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.context.teacherLesson.0"),
t("chat.suggestedPrompts.context.teacherLesson.1"),
],
};
}
// 教师试卷
if (pathname.includes("/teacher/exams")) {
return {
systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
contextMessage: t("chat.contextMessage.teacherExam"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"),
t("chat.suggestedPrompts.context.teacherExam.0"),
t("chat.suggestedPrompts.context.teacherExam.1"),
],
};
}
// 学生错题本
if (pathname.includes("/student/error-book")) {
return {
systemPrompt:
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
contextMessage: t("chat.contextMessage.studentErrorBook"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"),
t("chat.suggestedPrompts.student.2"),
],
};
}
// 学生作业
if (
pathname.includes("/student/homework") ||
pathname.includes("/student/learning")
) {
return {
systemPrompt:
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
contextMessage: t("chat.contextMessage.studentHomework"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.context.studentHomework.0"),
t("chat.suggestedPrompts.context.studentHomework.1"),
],
};
}
// 家长面板
if (pathname.includes("/parent")) {
return {
systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
contextMessage: t("chat.contextMessage.parent"),
suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"),
],
};
}
// 管理员面板
if (pathname.includes("/admin")) {
return {
systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
contextMessage: t("chat.contextMessage.admin"),
suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"),
],
};
}
// 默认
return {
systemPrompt:
"You are a helpful AI assistant for a K12 school management system.",
contextMessage: "",
suggestedPrompts: undefined,
};
}

View File

@@ -0,0 +1,24 @@
"use client";
import dynamic from "next/dynamic";
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* 全局 AI 助手悬浮球 —— 懒加载入口Phase 4.7
*
* 通过 next/dynamic 以 ssr:false 懒加载 AiAssistantWidgetInner
* 使其依赖的 AI SDK / Markdown 渲染等重型依赖被打入独立 chunk
* 避免污染首屏 bundle。加载完成前展示 Skeleton 占位骨架。
*/
const LazyAiAssistantWidgetInner = dynamic(
() =>
import("./ai-assistant-widget-inner").then((m) => m.AiAssistantWidgetInner),
{
ssr: false,
loading: () => <Skeleton className="h-12 w-full" />,
},
);
export function AiAssistantWidget(): React.ReactNode {
return <LazyAiAssistantWidgetInner />;
}

View File

@@ -0,0 +1,374 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
XAxis,
YAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/ui/chart";
import { cn } from "@/shared/lib/utils";
import { AiChartSpecSchema } from "../schema";
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 };
const CARTESIAN_GRID_PROPS = {
vertical: false,
strokeDasharray: "4 4",
strokeOpacity: 0.4,
} as const;
const POLAR_GRID_PROPS = { strokeOpacity: 0.4 } as const;
const BAR_X_AXIS_PROPS = {
tickLine: false,
axisLine: false,
tickMargin: 8,
} as const;
const BAR_Y_AXIS_PROPS = {
allowDecimals: true,
tickLine: false,
axisLine: false,
width: 36,
} as const;
const LINE_Y_AXIS_PROPS = {
tickLine: false,
axisLine: false,
width: 36,
} as const;
const LINE_TOOLTIP_CURSOR = {
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
} as const;
const LINE_ACTIVE_DOT = { r: 5, strokeWidth: 0 } as const;
const POLAR_ANGLE_TICK = { fontSize: 12 } as const;
const POLAR_RADIUS_TICK = { fontSize: 10 } as const;
const BAR_RADIUS_TOP: [number, number, number, number] = [4, 4, 0, 0];
const DEFAULT_Y_DOMAIN: [number, number] = [0, 100];
function formatPercentTick(value: number): string {
return `${value}%`;
}
function formatPieLabel(entry: { name?: string; value?: number }): string {
return `${entry.name ?? ""}: ${entry.value ?? ""}`;
}
/**
* AI 图表渲染器
*
* 将 AI 返回的图表 JSON 规格渲染为 recharts 图表。
* 支持 4 种图表类型bar / line / pie / radar。
*
* AI 通过在 Markdown 中返回特殊代码块触发渲染:
*
* ```chart:bar
* { "title": "...", "data": [...], "series": [...] }
* ```
*
* 规格格式(通用):
* {
* "title": "图表标题", // 可选
* "description": "说明文字", // 可选
* "data": [ // 数据数组
* { "name": "数学", "score": 85, "fullTitle": "数学科目" }
* ],
* "xKey": "name", // X 轴字段bar/line
* "series": [ // 系列配置
* { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }
* ],
* "yDomain": [0, 100], // 可选Y 轴定义域
* "height": 280 // 可选,高度 px
* }
*
* pie 图特有:
* {
* "data": [{ "name": "及格", "value": 30 }],
* "series": [{ "name": "分布" }]
* }
*/
export type AiChartType = "bar" | "line" | "pie" | "radar";
export interface AiChartSeries {
dataKey: string;
name: string;
color?: string;
/** pie: 填充透明度radar: 填充透明度 */
fillOpacity?: number;
/** radar: 线宽 */
strokeWidth?: number;
/** radar: 虚线 */
strokeDasharray?: string;
}
export interface AiChartSpec {
title?: string;
description?: string;
type?: AiChartType;
data: Array<Record<string, string | number>>;
xKey?: string;
angleKey?: string;
series: AiChartSeries[];
yDomain?: [number, number];
height?: number;
showLegend?: boolean;
}
/** 默认调色板(色盲友好) */
const DEFAULT_PALETTE = [
"hsl(221, 83%, 53%)", // 蓝
"hsl(142, 71%, 45%)", // 绿
"hsl(43, 96%, 56%)", // 黄
"hsl(0, 84%, 60%)", // 红
"hsl(271, 76%, 53%)", // 紫
"hsl(199, 89%, 48%)", // 青
"hsl(25, 95%, 53%)", // 橙
"hsl(280, 65%, 60%)", // 品红
];
interface AiChartRendererProps {
/** 图表类型 */
type: AiChartType;
/** JSON 规格字符串 */
spec: string;
className?: string;
}
/**
* 解析 JSON 规格,失败时返回 null
*
* 使用 Zod schema 校验,避免 as 断言。
*/
function parseSpec(spec: string): AiChartSpec | null {
try {
const parsed: unknown = JSON.parse(spec);
const result = AiChartSpecSchema.safeParse(parsed);
if (!result.success) return null;
return result.data;
} catch {
return null;
}
}
/**
* 为 series 补充默认颜色
*/
function withDefaultColors(series: AiChartSeries[]): AiChartSeries[] {
return series.map((s, i) => ({
...s,
color: s.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length],
}));
}
export function AiChartRenderer({
type,
spec,
className,
}: AiChartRendererProps): React.ReactNode {
const t = useTranslations("ai");
const parsed = useMemo(() => parseSpec(spec), [spec]);
if (!parsed) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{t("chart.parseError")}
</div>
);
}
const series = withDefaultColors(parsed.series);
const height = parsed.height ?? 280;
// 构建 ChartConfig
const chartConfig: ChartConfig = {};
for (const s of series) {
chartConfig[s.dataKey] = {
label: s.name,
color: s.color,
};
}
return (
<div className={cn("my-2 rounded-lg border bg-card p-3", className)}>
{parsed.title ? (
<div className="mb-2">
<p className="text-sm font-medium leading-tight">{parsed.title}</p>
{parsed.description ? (
<p className="text-xs text-muted-foreground mt-0.5">
{parsed.description}
</p>
) : null}
</div>
) : null}
<ChartContainer
config={chartConfig}
className="w-full"
style={{ height: `${height}px` }}
>
{renderChart(type, parsed, series)}
</ChartContainer>
</div>
);
}
function renderChart(
type: AiChartType,
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
switch (type) {
case "bar":
return renderBarChart(spec, series);
case "line":
return renderLineChart(spec, series);
case "pie":
return renderPieChart(spec, series);
case "radar":
return renderRadarChart(spec, series);
default:
return null;
}
}
function renderBarChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
const xKey = spec.xKey ?? "name";
return (
<BarChart data={spec.data} margin={CHART_MARGIN}>
<CartesianGrid {...CARTESIAN_GRID_PROPS} />
<XAxis dataKey={xKey} {...BAR_X_AXIS_PROPS} />
<YAxis domain={spec.yDomain} {...BAR_Y_AXIS_PROPS} />
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Bar
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
fill={`var(--color-${s.dataKey})`}
radius={BAR_RADIUS_TOP}
/>
))}
</BarChart>
);
}
function renderLineChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
const xKey = spec.xKey ?? "name";
return (
<LineChart data={spec.data} margin={CHART_MARGIN}>
<CartesianGrid {...CARTESIAN_GRID_PROPS} />
<XAxis dataKey={xKey} {...BAR_X_AXIS_PROPS} />
<YAxis domain={spec.yDomain ?? DEFAULT_Y_DOMAIN} {...LINE_Y_AXIS_PROPS} />
{/* arbitrary-value: chart canvas fixed size */}
<ChartTooltip
cursor={LINE_TOOLTIP_CURSOR}
content={<ChartTooltipContent indicator="line" className="w-[220px]" />}
/>
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Line
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
type="monotone"
stroke={`var(--color-${s.dataKey})`}
strokeWidth={2}
dot={{
fill: `var(--color-${s.dataKey})`,
r: 3,
strokeWidth: 2,
}}
activeDot={LINE_ACTIVE_DOT}
/>
))}
</LineChart>
);
}
function renderPieChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
// Pie 图data 中每项 { name, value }series 仅取第一个作为图例名
const colors = series.map((s) => s.color ?? DEFAULT_PALETTE[0]);
const dataKey = series[0]?.dataKey ?? "value";
return (
<PieChart margin={CHART_MARGIN}>
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
<Pie
data={spec.data}
dataKey={dataKey}
nameKey={spec.xKey ?? "name"}
cx="50%"
cy="50%"
outerRadius="75%"
label={formatPieLabel}
labelLine={false}
>
{spec.data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
</PieChart>
);
}
function renderRadarChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
const angleKey = spec.angleKey ?? spec.xKey ?? "name";
return (
<RadarChart data={spec.data} outerRadius="75%" margin={CHART_MARGIN}>
<PolarGrid {...POLAR_GRID_PROPS} />
<PolarAngleAxis dataKey={angleKey} tick={POLAR_ANGLE_TICK} />
<PolarRadiusAxis
domain={spec.yDomain ?? DEFAULT_Y_DOMAIN}
tickFormatter={formatPercentTick}
tick={POLAR_RADIUS_TICK}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Radar
key={s.dataKey}
name={s.name}
dataKey={s.dataKey}
stroke={`var(--color-${s.dataKey})`}
fill={`var(--color-${s.dataKey})`}
fillOpacity={s.fillOpacity ?? 0.3}
strokeWidth={s.strokeWidth}
strokeDasharray={s.strokeDasharray}
/>
))}
</RadarChart>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useTranslations } from "next-intl";
import { Send, Square } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Textarea } from "@/shared/components/ui/textarea";
type AiChatInputProps = {
value: string;
onChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
streaming: boolean;
onSend: () => void;
onStop: () => void;
maxReached: boolean;
variant: "card" | "widget";
placeholder?: string;
};
/**
* AI 聊天输入框
*
* 渲染文本输入区域与发送/停止按钮,支持 card / widget 两种视觉变体。
* 纯展示组件,所有状态由容器通过 props 注入。
*/
export function AiChatInput({
value,
onChange,
onKeyDown,
streaming,
onSend,
onStop,
maxReached,
variant,
placeholder,
}: AiChatInputProps): React.ReactNode {
const t = useTranslations("ai");
const isWidget = variant === "widget";
if (isWidget) {
return (
<div className="border-t p-3">
<div className="flex items-end gap-2">
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-10 max-h-32 resize-none rounded-2xl"
disabled={streaming || maxReached}
aria-label={t("chat.inputLabel")}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
className="rounded-full shrink-0"
onClick={onStop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
className="rounded-full shrink-0"
onClick={onSend}
disabled={!value.trim() || maxReached}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{maxReached ? (
<p className="text-xs text-muted-foreground text-center mt-2">
{t("chat.maxReached")}
</p>
) : null}
</div>
);
}
return (
<>
<div className="flex gap-2">
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-16 resize-none"
disabled={streaming || maxReached}
aria-label={t("chat.inputLabel")}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
onClick={onStop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
onClick={onSend}
disabled={!value.trim() || maxReached}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
{maxReached ? (
<p className="text-xs text-muted-foreground text-center">
{t("chat.maxReached")}
</p>
) : null}
</>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import type { RefObject } from "react";
import { useTranslations } from "next-intl";
import { Bot, User, Sparkles } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/components/ui/button";
import { ScrollArea } from "@/shared/components/ui/scroll-area";
import { AiMarkdownRenderer } from "./ai-markdown-renderer";
import type { AiChatMessage } from "../types";
type AiChatMessagesProps = {
messages: AiChatMessage[];
streaming: boolean;
variant: "card" | "widget";
scrollRef: RefObject<HTMLDivElement | null>;
suggestedPrompts: string[];
onSuggestedPrompt: (prompt: string) => void;
};
/**
* AI 聊天消息列表:渲染消息气泡、流式指示器、空状态建议提示词。
* 支持 card / widget 两种视觉变体。纯展示组件,状态由容器注入。
*/
export function AiChatMessages({
messages,
streaming,
variant,
scrollRef,
suggestedPrompts,
onSuggestedPrompt,
}: AiChatMessagesProps): React.ReactNode {
const t = useTranslations("ai");
const isWidget = variant === "widget";
if (messages.length > 0) {
if (isWidget) {
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2.5",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{message.role === "assistant" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-4 w-4" />
</span>
)}
<div
className={cn(
"rounded-2xl px-3.5 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-muted rounded-tl-sm",
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2.5 justify-start">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
<div className="rounded-2xl rounded-tl-sm px-3.5 py-2 text-sm bg-muted">
<span className="inline-flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</span>
</div>
</div>
) : null}
</div>
</div>
);
}
return (
<ScrollArea
className="h-72 w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-3" ref={scrollRef}>
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
) : (
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)}
<div
className={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2 justify-start">
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
<div className="rounded-md px-3 py-2 text-sm bg-muted">
<span className="animate-pulse">{t("chat.streaming")}</span>
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
</div>
</div>
) : null}
</div>
</ScrollArea>
);
}
// 空状态:建议提示词
if (isWidget) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-8">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md mb-3">
<Sparkles className="h-7 w-7" />
</span>
<p className="text-base font-medium mb-1">{t("widget.welcome")}</p>
<p className="text-sm text-muted-foreground mb-4 text-center">
{t("widget.welcomeDesc")}
</p>
<div className="flex flex-wrap gap-2 justify-center max-w-sm">
{suggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs rounded-full"
onClick={() => onSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
);
}
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
{t("chat.suggestedPrompts.title")}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{suggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,263 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Bot, Trash2 } from "lucide-react";
import { notify } from "@/shared/lib/notify";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { AiChatMessages } from "./ai-chat-messages";
import { AiChatInput } from "./ai-chat-input";
import { useAiChatStream } from "../hooks/use-ai-chat-stream";
import type { AiChatMessage } from "../types";
type AiChatPanelProps = {
/** 初始系统提示词 */
systemPrompt?: string;
/** 上下文信息(注入到 user message 前面) */
contextMessage?: string;
/** 占位提示文本 */
placeholder?: string;
/** 标题 */
title?: string;
/** 最大消息数 */
maxMessages?: number;
/** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[];
/** 视觉变体card默认卡片/ widget悬浮球内嵌无边框撑满容器 */
variant?: "card" | "widget";
};
/**
* AI 聊天面板
*
* 通用 AI 对话组件,可嵌入任何页面。
* V2 增强:
* - 流式响应SSE逐 token 渲染
* - Markdown 渲染(代码块、表格、列表)
* - 复制按钮
* - 停止生成按钮
* - 清除对话按钮
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage防抖写入
*
* 容器职责:持有 useAiChatStream流式状态机 + AbortController ref
* 输入状态、事件编排。渲染委托给 AiChatMessages / AiChatInput 子组件。
*/
export function AiChatPanel({
systemPrompt,
contextMessage,
placeholder,
title,
maxMessages = 50,
suggestedPrompts,
variant = "card",
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai");
const { messages, streaming, error, send, stop, clear } = useAiChatStream();
const [input, setInput] = useState("");
const [confirmClear, setConfirmClear] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const isWidget = variant === "widget";
const maxReached = messages.length >= maxMessages;
// 自动滚动到底部
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = useCallback(
async (content?: string): Promise<void> => {
const trimmed = (content ?? input).trim();
if (!trimmed || streaming || maxReached) return;
// 上下文信息合并到 systemPrompt 发送给 AI用户气泡只显示真实输入
const fullSystemPrompt = contextMessage
? `${systemPrompt ?? ""}\n\n[Page Context]\n${contextMessage}`.trim()
: systemPrompt;
const requestMessages: AiChatMessage[] = [
...messages,
{ role: "user", content: trimmed },
];
setInput("");
await send(requestMessages, { systemPrompt: fullSystemPrompt });
},
[
input,
streaming,
messages,
maxReached,
systemPrompt,
contextMessage,
send,
],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
};
const handleClear = (): void => {
setConfirmClear(true);
};
const handleConfirmClear = (): void => {
clear();
setConfirmClear(false);
notify.success(t("chat.clear"));
};
if (streaming && messages.length === 0) {
return <SkeletonCard variant="chart" />;
}
const defaultSuggestedPrompts = suggestedPrompts ?? [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.teacher.2"),
];
if (isWidget) {
return (
<div className="flex h-full flex-col">
{error ? (
<div
className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
role="alert"
>
{error}
</div>
) : null}
<AiChatMessages
messages={messages}
streaming={streaming}
variant={variant}
scrollRef={scrollRef}
suggestedPrompts={defaultSuggestedPrompts}
onSuggestedPrompt={(prompt) => void handleSend(prompt)}
/>
<AiChatInput
value={input}
onChange={setInput}
onKeyDown={handleKeyDown}
streaming={streaming}
onSend={() => void handleSend()}
onStop={stop}
maxReached={maxReached}
variant={variant}
placeholder={placeholder}
/>
</div>
);
}
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{title ?? t("chat.title")}
</CardTitle>
{messages.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground"
onClick={handleClear}
aria-label={t("chat.clear")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("chat.clear")}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{error ? (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
role="alert"
>
{error}
</div>
) : null}
<AiChatMessages
messages={messages}
streaming={streaming}
variant={variant}
scrollRef={scrollRef}
suggestedPrompts={defaultSuggestedPrompts}
onSuggestedPrompt={(prompt) => void handleSend(prompt)}
/>
<AiChatInput
value={input}
onChange={setInput}
onKeyDown={handleKeyDown}
streaming={streaming}
onSend={() => void handleSend()}
onStop={stop}
maxReached={maxReached}
variant={variant}
placeholder={placeholder}
/>
</CardContent>
</Card>
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("chat.clearConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("chat.clearConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("chat.clear")}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmClear}>
{t("chat.clear")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,295 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, Lightbulb, BookOpen, TrendingDown } from "lucide-react";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
import type {
WeaknessAnalysisResult,
SimilarQuestionResult,
} from "@/features/teacher/ai/types";
type AiErrorBookAnalysisProps = {
/** 错题列表(用于薄弱点分析) */
errorItems: Array<{
questionText: string;
questionType: string;
knowledgePointIds?: string[];
errorCount: number;
masteryLevel: number;
}>;
/** 学生 ID */
studentId: string;
/** 学科 ID */
subjectId?: string;
/** 当前错题的题目文本(用于相似题推荐) */
currentQuestionText?: string;
/** 当前题目类型 */
currentQuestionType?: string;
/** 选中相似题后的回调 */
onSelectSimilarQuestion?: (question: SimilarQuestionResult) => void;
};
/**
* 错题本 AI 分析组件
*
* 集成两个 AI 能力:
* 1. 相似题推荐:根据当前错题生成同类练习
* 2. 薄弱点分析:分析错题分布,生成学习建议
*
* 通过 AiClientProvider 注入服务,不直接 import actions。
*/
export function AiErrorBookAnalysis({
errorItems,
studentId,
subjectId,
currentQuestionText,
currentQuestionType,
onSelectSimilarQuestion,
}: AiErrorBookAnalysisProps): React.ReactNode {
const t = useTranslations("ai");
const aiClient = useAiClient();
const [similarLoading, setSimilarLoading] = useState(false);
const [weaknessLoading, setWeaknessLoading] = useState(false);
const [similarQuestions, setSimilarQuestions] = useState<
SimilarQuestionResult[]
>([]);
const [weaknessResult, setWeaknessResult] =
useState<WeaknessAnalysisResult | null>(null);
const handleGenerateSimilar = async (): Promise<void> => {
if (!currentQuestionText || !currentQuestionType) return;
setSimilarLoading(true);
try {
const result = await aiClient.suggestSimilarQuestions({
questionText: currentQuestionText,
questionType: currentQuestionType,
subject: subjectId,
count: 3,
});
if (result.success && result.data) {
setSimilarQuestions(result.data);
notify.success(t("suggestion.loaded"));
} else {
notify.error(result.message ?? t("suggestion.error"));
}
} catch {
notify.error(t("suggestion.error"));
} finally {
setSimilarLoading(false);
}
};
const handleAnalyzeWeakness = async (): Promise<void> => {
if (errorItems.length === 0) return;
setWeaknessLoading(true);
try {
const result = await aiClient.analyzeWeakness({
studentId,
subjectId,
errorItems,
});
if (result.success && result.data) {
setWeaknessResult(result.data);
notify.success(t("errorBook.weaknessAnalysis"));
} else {
notify.error(result.message ?? t("error.analysisFailed"));
}
} catch {
notify.error(t("error.analysisFailed"));
} finally {
setWeaknessLoading(false);
}
};
const severityVariant = (
severity: "high" | "medium" | "low",
): "destructive" | "secondary" | "outline" => {
if (severity === "high") return "destructive";
if (severity === "medium") return "secondary";
return "outline";
};
return (
<SectionErrorBoundary>
<div className="space-y-4">
{/* 相似题推荐 */}
{currentQuestionText ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("errorBook.similarQuestions")}
</CardTitle>
<CardDescription>{t("suggestion.title")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{similarLoading ? (
<SkeletonCard variant="chart" />
) : similarQuestions.length > 0 ? (
<>
{similarQuestions.map((question, index) => (
<div
key={index}
className="rounded-md border p-3 space-y-2"
>
<p className="text-sm">{question.text}</p>
{question.difficulty ? (
<Badge variant="outline" className="text-xs">
{t("suggestion.difficulty")}: {question.difficulty}
</Badge>
) : null}
{question.options && question.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{question.options.map((opt, optIndex) => (
<li key={optIndex}>
<span className="font-medium">{opt.id}.</span>{" "}
{opt.text}
</li>
))}
</ul>
) : null}
{question.explanation ? (
<p className="text-xs text-muted-foreground italic">
{question.explanation}
</p>
) : null}
{onSelectSimilarQuestion ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSelectSimilarQuestion(question)}
>
{t("suggestion.select")}
</Button>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateSimilar}
className="w-full"
>
{t("suggestion.regenerate")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateSimilar}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
) : null}
{/* 薄弱点分析 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-primary" />
{t("errorBook.weaknessAnalysis")}
</CardTitle>
<CardDescription>{t("errorBook.weakAreas")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{weaknessLoading ? (
<SkeletonCard variant="chart" />
) : weaknessResult ? (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-sm font-medium">
{t("errorBook.weakAreas")}
</h4>
{weaknessResult.weakAreas.map((area, index) => (
<div
key={index}
className="rounded-md border p-3 space-y-1"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{area.area}</span>
<Badge variant={severityVariant(area.severity)}>
{t(`errorBook.severity.${area.severity}`)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{area.suggestion}
</p>
</div>
))}
</div>
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<Lightbulb className="h-3.5 w-3.5" />
{t("errorBook.studyPlan")}
</h4>
<p className="text-sm text-muted-foreground">
{weaknessResult.studyPlan}
</p>
</div>
{weaknessResult.recommendedResources.length > 0 ? (
<div className="space-y-1">
<h4 className="text-sm font-medium flex items-center gap-1">
<BookOpen className="h-3.5 w-3.5" />
{t("errorBook.recommendedResources")}
</h4>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
{weaknessResult.recommendedResources.map(
(resource, index) => (
<li key={index}>{resource}</li>
),
)}
</ul>
</div>
) : null}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAnalyzeWeakness}
className="w-full"
>
{t("suggestion.regenerate")}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAnalyzeWeakness}
disabled={errorItems.length === 0}
className="w-full"
>
<TrendingDown className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
</div>
</SectionErrorBoundary>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, Check } from "lucide-react";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Progress } from "@/shared/components/ui/progress";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
import type { GradingSuggestion } from "@/features/teacher/ai/types";
type AiGradingAssistProps = {
/** 题目文本 */
questionText: string;
/** 题目类型 */
questionType: string;
/** 学生答案 */
studentAnswer: string;
/** 正确答案(可选) */
correctAnswer?: string;
/** 最大分值 */
maxScore: number;
/** 学科 */
subject?: string;
/** 应用建议分数 */
onApplyScore?: (score: number) => void;
/** 应用建议反馈 */
onApplyFeedback?: (feedback: string) => void;
};
/**
* AI 批改辅助组件
*
* 为教师提供 AI 预评分与反馈建议。
* 仅用于主观题text/essay客观题由系统自动判分。
*/
export function AiGradingAssist({
questionText,
questionType,
studentAnswer,
correctAnswer,
maxScore,
subject,
onApplyScore,
onApplyFeedback,
}: AiGradingAssistProps): React.ReactNode {
const t = useTranslations("ai");
const aiClient = useAiClient();
const [loading, setLoading] = useState(false);
const [suggestion, setSuggestion] = useState<GradingSuggestion | null>(null);
// 仅对主观题提供 AI 批改
const isAutoGradable =
questionType === "single_choice" ||
questionType === "multiple_choice" ||
questionType === "judgment";
if (isAutoGradable) {
return null;
}
const handleGenerate = async (): Promise<void> => {
setLoading(true);
try {
const result = await aiClient.suggestGrading({
questionText,
questionType,
studentAnswer,
correctAnswer,
maxScore,
subject,
});
if (result.success && result.data) {
setSuggestion(result.data);
notify.success(t("grading.title"));
} else {
notify.error(result.message ?? t("grading.error"));
}
} catch {
notify.error(t("grading.error"));
} finally {
setLoading(false);
}
};
const confidencePercent = suggestion
? Math.round(suggestion.confidence * 100)
: 0;
return (
<SectionErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("grading.title")}
</CardTitle>
<CardDescription>{t("grading.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{loading ? (
<SkeletonCard variant="chart" />
) : suggestion ? (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("grading.suggestedScore")}
</span>
<Badge variant="secondary" className="text-base">
{suggestion.suggestedScore} / {maxScore}
</Badge>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t("grading.confidence")}</span>
<span>{confidencePercent}%</span>
</div>
<Progress value={confidencePercent} className="h-1.5" />
</div>
</div>
<div className="space-y-1">
<h4 className="text-sm font-medium">{t("grading.feedback")}</h4>
<p className="text-sm text-muted-foreground rounded-md bg-muted p-2">
{suggestion.feedback}
</p>
</div>
<div className="space-y-1">
<h4 className="text-sm font-medium">
{t("grading.reasoning")}
</h4>
<p className="text-xs text-muted-foreground">
{suggestion.reasoning}
</p>
</div>
<div className="flex gap-2">
{onApplyScore ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onApplyScore(suggestion.suggestedScore);
notify.success(t("grading.suggestedScore"));
}}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("grading.applyScore")}
</Button>
) : null}
{onApplyFeedback ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onApplyFeedback(suggestion.feedback);
notify.success(t("grading.feedback"));
}}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("grading.applyFeedback")}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleGenerate}
>
{t("suggestion.regenerate")}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("grading.title")}
</Button>
)}
</CardContent>
</Card>
</SectionErrorBoundary>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
Sparkles,
BookOpen,
Lightbulb,
HelpCircle,
FileText,
} from "lucide-react";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/components/ui/card";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
import type { LessonContentResult } from "@/features/teacher/ai/types";
type ContentType = "activity" | "assessment" | "question" | "material";
type AiLessonContentGeneratorProps = {
/** 备课主题 */
topic: string;
/** 学科 */
subject?: string;
/** 年级 */
grade?: string;
/** 教材 ID */
textbookId?: string;
/** 章节 ID */
chapterId?: string;
/** 生成内容后的回调 */
onInsertContent?: (result: LessonContentResult) => void;
};
/**
* AI 备课内容生成器
*
* 为教师提供 AI 生成教学活动、评估题、讨论题、教学素材的能力。
* 通过 AiClientProvider 注入服务,不直接 import actions。
*
* 使用场景:在备课编辑器侧边栏中作为辅助工具使用。
*/
export function AiLessonContentGenerator({
topic,
subject,
grade,
textbookId,
chapterId,
onInsertContent,
}: AiLessonContentGeneratorProps): React.ReactNode {
const t = useTranslations("ai");
const aiClient = useAiClient();
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<LessonContentResult | null>(null);
const [activeType, setActiveType] = useState<ContentType>("activity");
const [additionalContext, setAdditionalContext] = useState("");
const handleGenerate = async (): Promise<void> => {
if (!topic.trim()) {
notify.error(t("lessonPrep.error"));
return;
}
setLoading(true);
try {
const response = await aiClient.generateLessonContent({
topic,
subject,
grade,
textbookId,
chapterId,
contentType: activeType,
additionalContext: additionalContext.trim() || undefined,
});
if (response.success && response.data) {
setResult(response.data);
notify.success(t("lessonPrep.generateContent"));
} else {
notify.error(response.message ?? t("lessonPrep.error"));
}
} catch {
notify.error(t("lessonPrep.error"));
} finally {
setLoading(false);
}
};
const contentTypes: Array<{
type: ContentType;
label: string;
icon: typeof Sparkles;
}> = [
{
type: "activity",
label: t("lessonPrep.generateActivity"),
icon: Lightbulb,
},
{
type: "assessment",
label: t("lessonPrep.generateAssessment"),
icon: FileText,
},
{
type: "question",
label: t("lessonPrep.generateQuestion"),
icon: HelpCircle,
},
{
type: "material",
label: t("lessonPrep.generateContent"),
icon: BookOpen,
},
];
return (
<SectionErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("lessonPrep.generateContent")}
</CardTitle>
<CardDescription>{t("lessonPrep.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 内容类型选择 */}
<div className="grid grid-cols-2 gap-2">
{contentTypes.map(({ type, label, icon: Icon }) => (
<Button
key={type}
type="button"
variant={activeType === type ? "default" : "outline"}
size="sm"
className="justify-start"
onClick={() => setActiveType(type)}
>
<Icon className="mr-1 h-3.5 w-3.5" />
{label}
</Button>
))}
</div>
{/* 附加上下文 */}
<div className="space-y-1">
<label
className="text-xs text-muted-foreground"
htmlFor="ai-additional-context"
>
{t("lessonPrep.additionalContext")}
</label>
<Textarea
id="ai-additional-context"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder={t("lessonPrep.additionalContextPlaceholder")}
className="min-h-16 text-sm"
maxLength={500}
/>
</div>
{/* 生成按钮 */}
<Button
type="button"
onClick={handleGenerate}
disabled={loading || !topic.trim()}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{loading
? t("lessonPrep.loading")
: t("lessonPrep.generateContent")}
</Button>
{/* 生成结果 */}
{loading ? (
<SkeletonCard variant="chart" />
) : result ? (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">{result.title}</h4>
<Badge variant="secondary" className="text-xs">
{activeType}
</Badge>
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{result.content}
</p>
{onInsertContent ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onInsertContent(result);
notify.success(t("lessonPrep.insertContent"));
}}
>
{t("lessonPrep.insertContent")}
</Button>
) : null}
</div>
) : null}
</CardContent>
</Card>
</SectionErrorBoundary>
);
}

View File

@@ -0,0 +1,164 @@
"use client";
import { memo, useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Copy, Check } from "lucide-react";
import { useTranslations } from "next-intl";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import { AiChartRenderer, type AiChartType } from "./ai-chart-renderer";
type AiMarkdownRendererProps = {
content: string;
/** 是否显示复制按钮 */
showCopyButton?: boolean;
/** 自定义类名 */
className?: string;
};
/** 支持的图表类型映射language → chart type */
const CHART_LANG_PREFIX = "chart:";
const CHART_TYPES: Record<string, AiChartType> = {
"chart:bar": "bar",
"chart:line": "line",
"chart:pie": "pie",
"chart:radar": "radar",
};
/**
* 类型守卫:判断字符串是否为合法的 AiChartType
*/
function isAiChartType(value: string): value is AiChartType {
return (
value === "bar" || value === "line" || value === "pie" || value === "radar"
);
}
/**
* 从 language 标识中解析图表类型
*/
function resolveChartType(lang: string): AiChartType | undefined {
// 优先查表(兼容 "chart:bar" 形式)
const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`];
if (fromTable) return fromTable;
// 兼容 "chart:bar" 前缀形式
if (lang.startsWith(CHART_LANG_PREFIX)) {
const suffix = lang.slice(CHART_LANG_PREFIX.length);
return isAiChartType(suffix) ? suffix : undefined;
}
return undefined;
}
/**
* AI Markdown 渲染器
*
* 将 AI 回复渲染为富文本 Markdown支持
* - GFM表格、删除线、任务列表
* - 代码块语法高亮
* - 图表渲染(```chart:bar|line|pie|radar + JSON
* - 复制按钮
*
* 安全react-markdown 默认不执行 HTML防止 XSS。
*/
function AiMarkdownRendererImpl({
content,
showCopyButton = true,
className,
}: AiMarkdownRendererProps): React.ReactNode {
const t = useTranslations("ai");
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async (): Promise<void> => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
notify.success(t("chat.copied"));
setTimeout(() => setCopied(false), 2000);
} catch {
notify.error(t("error.chatFailed"));
}
}, [content, t]);
return (
<div className="group relative">
<div
className={cn(
"prose prose-sm dark:prose-invert max-w-none",
"prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0",
"prose-code:before:content-none prose-code:after:content-none",
"prose-pre:bg-muted prose-pre:p-3 prose-pre:rounded-md",
className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className: codeClass, children, ...props }) {
const isInline = !codeClass?.includes("language-");
if (isInline) {
return (
<code
className="rounded bg-muted px-1 py-0.5 text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// 检测图表代码块language-chart:bar / chart:line / chart:pie / chart:radar
const lang = codeClass?.replace("language-", "").trim() ?? "";
const chartType = resolveChartType(lang);
if (chartType) {
const raw = String(children).replace(/\n$/, "");
return <AiChartRenderer type={chartType} spec={raw} />;
}
return (
<code className={codeClass} {...props}>
{children}
</code>
);
},
a({ children, ...props }) {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
{children}
</a>
);
},
}}
>
{content}
</ReactMarkdown>
</div>
{showCopyButton ? (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity h-7 px-2"
onClick={() => void handleCopy()}
aria-label={t("chat.copy")}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>
);
}
export const AiMarkdownRenderer = memo(AiMarkdownRendererImpl);

View File

@@ -0,0 +1,69 @@
"use client";
import { useTranslations } from "next-intl";
import { Label } from "@/shared/components/ui/label";
import { Select, type SelectOption } from "@/shared/components/ui/select";
/** AI Provider 摘要信息(与 settings 模块类型兼容) */
export type AiProviderOption = {
id: string;
provider: string;
model: string;
isDefault: boolean;
};
type AiProviderSelectorProps = {
/** 当前选中的 provider ID */
value: string;
/** 值变更回调 */
onChange: (value: string) => void;
/** Provider 列表 */
providers: AiProviderOption[];
/** 是否加载中 */
loading?: boolean;
/** Provider 标签映射 */
providerLabels?: Record<string, string>;
/** 字段 ID */
id?: string;
};
/**
* AI Provider 选择器
*
* 可复用的受控选择组件,用于选择 AI Provider。
* portal-shell 适配:使用原生 Select无 react-hook-form 依赖)。
*/
export function AiProviderSelector({
value,
onChange,
providers,
loading = false,
providerLabels,
id = "ai-provider",
}: AiProviderSelectorProps): React.ReactNode {
const t = useTranslations("ai");
const options: SelectOption[] = providers.map((item) => ({
value: item.id,
label: `${providerLabels?.[item.provider] ?? item.provider} · ${item.model}${item.isDefault ? ` (${t("provider.default")})` : ""}`,
}));
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("provider.label")}</Label>
<Select
id={id}
value={value}
onValueChange={onChange}
options={options}
placeholder={
loading ? t("provider.loading") : t("provider.placeholder")
}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
{t("provider.description")}
</p>
</div>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, RefreshCw, Plus } from "lucide-react";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Select, type SelectOption } from "@/shared/components/ui/select";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { SkeletonCard } from "@/shared/components/ui/skeleton";
import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
import type { QuestionVariantResult } from "@/features/teacher/ai/types";
type VariantType =
"same_knowledge_point" | "different_difficulty" | "different_format";
const VARIANT_TYPES: readonly string[] = [
"same_knowledge_point",
"different_difficulty",
"different_format",
];
const isVariantType = (value: string): value is VariantType => {
return VARIANT_TYPES.includes(value);
};
type AiQuestionVariantGeneratorProps = {
/** 原始题目 */
originalQuestion: {
text: string;
type: string;
difficulty?: number;
options?: Array<{ id: string; text: string; isCorrect?: boolean }>;
answer?: string;
};
/** 学科 */
subject?: string;
/** 生成变体后的回调 */
onAddVariant?: (variant: QuestionVariantResult) => void;
};
/**
* AI 题目变体生成器
*
* 为教师提供从现有题目生成变体的能力:
* - same_knowledge_point: 同知识点不同表述
* - different_difficulty: 调整难度
* - different_format: 转换题型
*
* 通过 AiClientProvider 注入服务,不直接 import actions。
*/
export function AiQuestionVariantGenerator({
originalQuestion,
subject,
onAddVariant,
}: AiQuestionVariantGeneratorProps): React.ReactNode {
const t = useTranslations("ai");
const aiClient = useAiClient();
const [loading, setLoading] = useState(false);
const [variant, setVariant] = useState<QuestionVariantResult | null>(null);
const [variantType, setVariantType] = useState<VariantType>(
"same_knowledge_point",
);
const handleGenerate = async (): Promise<void> => {
if (!originalQuestion.text.trim()) {
notify.error(t("error.invalidInput"));
return;
}
setLoading(true);
try {
const result = await aiClient.generateQuestionVariant({
originalQuestion,
subject,
variantType,
});
if (result.success && result.data) {
setVariant(result.data);
notify.success(t("exam.generate"));
} else {
notify.error(result.message ?? t("error.variantFailed"));
}
} catch {
notify.error(t("error.variantFailed"));
} finally {
setLoading(false);
}
};
const variantTypeLabels: Record<VariantType, string> = {
same_knowledge_point: t("exam.variantType.same_knowledge_point"),
different_difficulty: t("exam.variantType.different_difficulty"),
different_format: t("exam.variantType.different_format"),
};
return (
<SectionErrorBoundary>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("capability.questionVariant")}
</CardTitle>
<CardDescription>{t("exam.variantType.label")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* 变体类型选择 */}
<div className="space-y-1">
<label
className="text-xs text-muted-foreground"
htmlFor="variant-type"
>
{t("exam.generate")}
</label>
<Select
id="variant-type"
className="w-full"
value={variantType}
onValueChange={(value) => {
if (isVariantType(value)) {
setVariantType(value);
}
}}
options={VARIANT_TYPES.map<SelectOption>((v) => ({
value: v,
label: variantTypeLabels[v as VariantType],
}))}
placeholder={t("exam.generate")}
/>
</div>
{/* 生成按钮 */}
<Button
type="button"
onClick={handleGenerate}
disabled={loading || !originalQuestion.text.trim()}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{loading ? t("exam.generating") : t("exam.generate")}
</Button>
{/* 生成结果 */}
{loading ? (
<SkeletonCard variant="chart" />
) : variant ? (
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">{variant.text}</h4>
<Badge variant="secondary" className="text-xs">
{t("suggestion.difficulty")}: {variant.difficulty}
</Badge>
</div>
{variant.options && variant.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{variant.options.map((opt, index) => (
<li key={index} className="flex items-center gap-1">
<span className="font-medium">{opt.id}.</span>
<span>{opt.text}</span>
{opt.isCorrect ? (
<Badge variant="outline" className="text-xs">
</Badge>
) : null}
</li>
))}
</ul>
) : null}
{variant.answer ? (
<div className="text-xs">
<span className="font-medium">{t("exam.sourceText")}:</span>{" "}
<span className="text-muted-foreground">
{variant.answer}
</span>
</div>
) : null}
{variant.explanation ? (
<p className="text-xs text-muted-foreground italic">
{variant.explanation}
</p>
) : null}
<div className="flex gap-2">
{onAddVariant ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onAddVariant(variant);
notify.success(t("exam.generate"));
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("exam.addVariant")}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleGenerate}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("suggestion.regenerate")}
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
</SectionErrorBoundary>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Activity, Users, AlertTriangle, Clock } from "lucide-react";
import { notify } from "@/shared/lib/notify";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Progress } from "@/shared/components/ui/progress";
import { Button } from "@/shared/components/ui/button";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { useAiClient } from "@/features/teacher/ai/context/ai-client-provider";
import type { AiUsageStats } from "@/features/teacher/ai/types";
/**
* 管理员 AI 使用统计仪表盘
*
* 参考 Khanmigo district dashboard 和 Century Tech 全校视图。
* 展示:
* - 总调用数 / 今日 / 本周
* - 活跃用户数
* - 错误率
* - 平均耗时
* - 按能力分类
* - 按角色分类
* - 高频用户
* - 最近活动
*/
export function AiUsageDashboard(): React.ReactNode {
const t = useTranslations("ai");
const aiClient = useAiClient();
const [stats, setStats] = useState<AiUsageStats | null>(null);
const [loading, setLoading] = useState(false);
const loadStats = useCallback(async (): Promise<void> => {
if (!aiClient.getAiUsageStats) return;
setLoading(true);
try {
const result = await aiClient.getAiUsageStats();
if (result.success && result.data) {
setStats(result.data);
} else {
notify.error(result.message ?? t("error.statsFailed"));
}
} catch {
notify.error(t("error.statsFailed"));
} finally {
setLoading(false);
}
}, [aiClient, t]);
useEffect(() => {
void loadStats();
}, [loadStats]);
const statCards = stats
? [
{
label: t("admin.totalCalls"),
value: stats.totalCalls.toString(),
icon: Activity,
color: "text-blue-500",
},
{
label: t("admin.callsToday"),
value: stats.callsToday.toString(),
icon: Clock,
color: "text-green-500",
},
{
label: t("admin.activeUsers"),
value: stats.activeUsers.toString(),
icon: Users,
color: "text-purple-500",
},
{
label: t("admin.errorRate"),
value: `${(stats.errorRate * 100).toFixed(1)}%`,
icon: AlertTriangle,
color: stats.errorRate > 0.05 ? "text-red-500" : "text-green-500",
},
]
: [];
return (
<SectionErrorBoundary title={t("admin.usageDashboard")}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary" />
{t("admin.usageDashboard")}
</CardTitle>
<CardDescription>
{t("admin.dashboardDescription")}
</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadStats()}
disabled={loading}
>
{t("suggestion.regenerate")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{loading && !stats ? (
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-20 rounded-md bg-muted animate-pulse"
/>
))}
</div>
) : stats ? (
<>
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-3">
{statCards.map((card, index) => {
const Icon = card.icon;
return (
<div
key={index}
className="rounded-md border p-3 space-y-1"
>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{card.label}
</span>
<Icon className={`h-3.5 w-3.5 ${card.color}`} />
</div>
<p className="text-2xl font-bold">{card.value}</p>
</div>
);
})}
</div>
{/* 按能力分类 */}
{stats.byCapability.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">
{t("admin.byCapability")}
</h4>
<div className="space-y-2">
{stats.byCapability.map((item, index) => {
const maxCount = Math.max(
...stats.byCapability.map((c) => c.count),
1,
);
const percent = (item.count / maxCount) * 100;
return (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{item.capability}
</span>
<span className="font-medium">{item.count}</span>
</div>
<Progress value={percent} className="h-1.5" />
</div>
);
})}
</div>
</div>
) : null}
{/* 按角色分类 */}
{stats.byRole.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("admin.byRole")}</h4>
<div className="flex flex-wrap gap-2">
{stats.byRole.map((item, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs"
>
{item.role}: {item.count}
</Badge>
))}
</div>
</div>
) : null}
{/* 高频用户 */}
{stats.topUsers.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("admin.topUsers")}</h4>
<div className="space-y-1">
{stats.topUsers.slice(0, 5).map((user, index) => (
<div
key={index}
className="flex items-center justify-between text-xs"
>
<span className="text-muted-foreground">
{user.userId}
</span>
<Badge variant="outline">{user.count}</Badge>
</div>
))}
</div>
</div>
) : null}
{/* 最近活动 */}
{stats.recentActivity.length > 0 ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">
{t("admin.recentActivity")}
</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{stats.recentActivity
.slice(0, 10)
.map((activity, index) => (
<div
key={index}
className="flex items-center justify-between text-xs border-b pb-1"
>
<span className="text-muted-foreground">
{activity.capability}
</span>
<div className="flex items-center gap-2">
<span
className={
activity.success
? "text-green-500"
: "text-red-500"
}
>
{activity.success ? "✓" : "✗"}
</span>
<span className="text-muted-foreground">
{activity.durationMs}ms
</span>
</div>
</div>
))}
</div>
</div>
) : null}
{stats.totalCalls === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("admin.noData")}
</div>
) : null}
</>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("admin.noData")}
</div>
)}
</CardContent>
</Card>
</SectionErrorBoundary>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { AiClientService } from "../types";
/**
* AI 客户端服务 Context
*
* 通过 React Context 注入 AiClientServiceServer Action 引用集合),
* 客户端组件通过 useAiClient() 消费,不直接 import actions。
*
* 遵循 settings 模块的依赖注入模式:
* - 页面层Server Component创建 service 对象并注入 Provider
* - 组件层通过 Hook 消费
* - 测试时可注入 mock service
*/
// 重新导出 AiClientService 类型,方便调用方从单一入口导入
export type { AiClientService } from "../types";
const AiClientContext = createContext<AiClientService | null>(null);
export function AiClientProvider({
children,
service,
}: {
children: ReactNode;
service: AiClientService;
}) {
return (
<AiClientContext.Provider value={service}>
{children}
</AiClientContext.Provider>
);
}
/**
* 获取 AI 客户端服务
*
* 必须在 AiClientProvider 内部使用。
* 若未注入,抛出错误以防止静默失败。
*/
export function useAiClient(): AiClientService {
const service = useContext(AiClientContext);
if (!service) {
throw new Error(
"useAiClient must be used within an AiClientProvider. " +
"Wrap your component tree with <AiClientProvider service={...}>.",
);
}
return service;
}
/**
* 安全获取 AI 客户端服务(未注入时返回 null
*
* 用于可选 AI 功能的场景,组件需自行处理 null 情况。
*/
export function useAiClientOptional(): AiClientService | null {
return useContext(AiClientContext);
}

View File

@@ -0,0 +1,101 @@
"use client";
/**
* 创建 AI 客户端服务portal-shell 适配版)
*
* 从 CICD src/modules/ai/context/create-ai-client-service.ts 迁移。
* 适配CICD 通过 Server Action 引用调用 AIportal-shell 为客户端微前端,
* 改为通过 fetch 调用 BFF 路由(/api/ai/*)。
*
* 返回 ActionState<T>(与 CICD Server Action 模式结构一致),便于上层组件统一处理。
*/
import type {
ActionState,
AiChatResult,
AiClientService,
ExplainErrorResult,
GradingSuggestion,
LessonContentResult,
QuestionVariantResult,
SimilarQuestionResult,
WeaknessAnalysisResult,
} from "../types";
/**
* 通用 fetch POST 封装,统一返回 ActionState<T>
*
* BFF 路由约定返回 { success: boolean; data?: T; message?: string } 结构。
*/
async function postJson<T>(
url: string,
body: unknown,
): Promise<ActionState<T>> {
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
success: false,
message: `AI BFF ${response.status}: ${text || response.statusText}`,
};
}
const json = (await response.json()) as ActionState<T>;
return json;
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
/**
* 创建完整的 AI 客户端服务(含全部能力,通过 BFF fetch
*
* 用于全局 layout 或需要全部 AI 能力的页面。
* 通过 React Context 注入,客户端组件通过 useAiClient() 消费。
*/
export function createFullAiClientService(): AiClientService {
return {
chat: (input) => postJson<AiChatResult>("/api/ai/chat", input),
suggestSimilarQuestions: (input) =>
postJson<SimilarQuestionResult[]>("/api/ai/similar-questions", input),
suggestGrading: (input) =>
postJson<GradingSuggestion>("/api/ai/grading", input),
generateLessonContent: (input) =>
postJson<LessonContentResult>("/api/ai/lesson-content", input),
generateQuestionVariant: (input) =>
postJson<QuestionVariantResult>("/api/ai/question-variant", input),
analyzeWeakness: (input) =>
postJson<WeaknessAnalysisResult>("/api/ai/weakness-analysis", input),
explainError: (input) =>
postJson<ExplainErrorResult>("/api/ai/explain-error", input),
};
}
/**
* 创建核心 AI 客户端服务(仅 6 个常用能力)
*
* 用于只需要 AI 业务能力(不含错题解释/统计等可选能力)的页面。
* 可选字段不注入,调用方组件需自行处理 undefined 情况。
*/
export function createCoreAiClientService(): AiClientService {
return {
chat: (input) => postJson<AiChatResult>("/api/ai/chat", input),
suggestSimilarQuestions: (input) =>
postJson<SimilarQuestionResult[]>("/api/ai/similar-questions", input),
suggestGrading: (input) =>
postJson<GradingSuggestion>("/api/ai/grading", input),
generateLessonContent: (input) =>
postJson<LessonContentResult>("/api/ai/lesson-content", input),
generateQuestionVariant: (input) =>
postJson<QuestionVariantResult>("/api/ai/question-variant", input),
analyzeWeakness: (input) =>
postJson<WeaknessAnalysisResult>("/api/ai/weakness-analysis", input),
};
}

View File

@@ -0,0 +1,141 @@
/**
* SSE 流式响应解析与处理工具
*
* 从 use-ai-chat-stream hook 中抽取的纯函数,便于单独测试与复用。
*/
import type { AiChatMessage } from "../types";
/** SSE 事件类型 */
export type SseEvent =
| { type: "token"; content: string }
| { type: "error"; message: string }
| { type: "filtered" }
| { type: "socratic_warning"; message: string };
/**
* 从 Response 中读取并解析 SSE 事件流
*
* @param response - fetch 返回的 Response 对象
* @param onEvent - 每个解析出的事件回调
*/
export async function consumeSseStream(
response: Response,
onEvent: (event: SseEvent) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data) as {
type: "token" | "error" | "filtered" | "socratic_warning";
content?: string;
message?: string;
};
if (parsed.type === "token" && parsed.content) {
onEvent({ type: "token", content: parsed.content });
} else if (parsed.type === "error") {
onEvent({
type: "error",
message: parsed.message ?? "Unknown error",
});
} else if (parsed.type === "filtered") {
onEvent({ type: "filtered" });
} else if (parsed.type === "socratic_warning") {
onEvent({
type: "socratic_warning",
message: parsed.message ?? "Socratic warning",
});
}
} catch {
// 忽略解析错误
}
}
}
}
/** 流式错误对应的 i18n key相对于 "ai" 命名空间) */
export type StreamErrorKey =
| "safety.dailyLimit"
| "error.unauthorized"
| "safety.blocked"
| "error.chatFailed";
/**
* 根据 HTTP 状态码映射错误消息 i18n key
*
* 返回值用于 next-intl 的 t() 调用(相对于 "ai" 命名空间)。
*/
export function getStreamErrorKey(status: number): StreamErrorKey {
if (status === 429) return "safety.dailyLimit";
if (status === 403) return "error.unauthorized";
if (status === 400) return "safety.blocked";
return "error.chatFailed";
}
/**
* 从错误响应体中提取错误消息
*/
export async function extractErrorMessage(
response: Response,
fallback: string,
): Promise<string> {
try {
const errorText = await response.text();
const errorData = JSON.parse(errorText) as { message?: string };
return errorData.message ?? fallback;
} catch {
return fallback;
}
}
/**
* 移除消息列表末尾空的 assistant 消息
*
* 用于流式失败/中止时清理占位消息。
*/
export function removeTrailingEmptyAssistant(
messages: AiChatMessage[],
): AiChatMessage[] {
const filtered = [...messages];
const last = filtered[filtered.length - 1];
if (last && last.role === "assistant" && last.content === "") {
filtered.pop();
}
return filtered;
}
/**
* 向消息列表末尾追加 token 到 assistant 消息
*/
export function appendTokenToLastAssistant(
messages: AiChatMessage[],
token: string,
): AiChatMessage[] {
const updated = [...messages];
const last = updated[updated.length - 1];
if (last && last.role === "assistant") {
updated[updated.length - 1] = {
...last,
content: last.content + token,
};
}
return updated;
}

View File

@@ -0,0 +1,177 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import type { AiChatMessage } from "../types";
import {
consumeSseStream,
getStreamErrorKey,
extractErrorMessage,
removeTrailingEmptyAssistant,
appendTokenToLastAssistant,
} from "./stream-utils";
const HISTORY_STORAGE_KEY = "ai-chat-history";
const MAX_HISTORY = 20;
/**
* AI 流式聊天 Hook
*
* 通过 SSE 端点消费流式 AI 回复。
* 支持:逐 token 渲染、停止生成AbortController、错误处理、历史持久化恢复。
*/
type UseAiChatStreamReturn = {
messages: AiChatMessage[];
streaming: boolean;
error: string | null;
send: (
messages: AiChatMessage[],
options?: { systemPrompt?: string; providerId?: string },
) => Promise<void>;
stop: () => void;
clear: () => void;
};
function isAiChatMessage(value: unknown): value is AiChatMessage {
if (value === null || typeof value !== "object") return false;
return (
"role" in value &&
(value.role === "system" ||
value.role === "user" ||
value.role === "assistant") &&
"content" in value &&
typeof value.content === "string"
);
}
function loadHistory(): AiChatMessage[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// 从 unknown 转换localStorage 数据不可信,用类型守卫过滤不合法的项
return parsed.filter(isAiChatMessage);
} catch {
return [];
}
}
export function useAiChatStream(): UseAiChatStreamReturn {
const t = useTranslations("ai");
// 懒初始化:从 localStorage 恢复历史
const [messages, setMessages] = useState<AiChatMessage[]>(() =>
loadHistory(),
);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// 持久化(防抖)
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (streaming) return;
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
try {
if (messages.length === 0) {
localStorage.removeItem(HISTORY_STORAGE_KEY);
} else {
localStorage.setItem(
HISTORY_STORAGE_KEY,
JSON.stringify(messages.slice(-MAX_HISTORY)),
);
}
} catch {
// ignore
}
}, 500);
return () => {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
};
}, [messages, streaming]);
const send = useCallback(
async (
inputMessages: AiChatMessage[],
options?: { systemPrompt?: string; providerId?: string },
): Promise<void> => {
if (streaming) return;
setStreaming(true);
setError(null);
const userMessage = inputMessages[inputMessages.length - 1];
if (userMessage && userMessage.role === "user") {
setMessages((prev) => [...prev, userMessage]);
}
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const response = await fetch("/api/ai/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: inputMessages,
systemPrompt: options?.systemPrompt,
providerId: options?.providerId,
}),
signal: controller.signal,
});
if (!response.ok) {
const fallback = t(getStreamErrorKey(response.status));
const errorMessage = await extractErrorMessage(response, fallback);
setError(errorMessage);
setMessages((prev) => removeTrailingEmptyAssistant(prev));
return;
}
await consumeSseStream(response, (event) => {
if (event.type === "token") {
setMessages((prev) =>
appendTokenToLastAssistant(prev, event.content),
);
} else if (event.type === "error") {
setError(event.message);
setMessages((prev) => removeTrailingEmptyAssistant(prev));
} else if (event.type === "filtered") {
setError(t("safety.contentFiltered"));
} else if (event.type === "socratic_warning") {
// 苏格拉底式辅导警告:不阻断,仅提示
// 可通过 toast 或 UI 标记展示,此处暂存 error 供组件判断
setError(event.message);
}
});
} catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
setStreaming(false);
abortControllerRef.current = null;
setMessages((prev) => removeTrailingEmptyAssistant(prev));
}
},
[streaming, t],
);
const stop = useCallback((): void => {
abortControllerRef.current?.abort();
}, []);
const clear = useCallback((): void => {
setMessages([]);
setError(null);
try {
localStorage.removeItem(HISTORY_STORAGE_KEY);
} catch {
// ignore
}
}, []);
return { messages, streaming, error, send, stop, clear };
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useState, useCallback } from "react";
import { useAiClient } from "../context/ai-client-provider";
import type {
SimilarQuestionInput,
SimilarQuestionResult,
GradingInput,
GradingSuggestion,
} from "../types";
/**
* AI 建议 Hook
*
* 封装 AI 建议调用逻辑(相似题、批改建议等),与 UI 分离。
*/
export function useAiSuggestion(): {
loading: boolean;
error: string | null;
suggestSimilarQuestions: (
input: SimilarQuestionInput,
) => Promise<SimilarQuestionResult[] | null>;
suggestGrading: (input: GradingInput) => Promise<GradingSuggestion | null>;
} {
const aiClient = useAiClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const suggestSimilarQuestions = useCallback(
async (
input: SimilarQuestionInput,
): Promise<SimilarQuestionResult[] | null> => {
setLoading(true);
setError(null);
try {
const result = await aiClient.suggestSimilarQuestions(input);
if (result.success && result.data) {
return result.data;
}
setError(result.message ?? "AI suggestion failed");
return null;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return null;
} finally {
setLoading(false);
}
},
[aiClient],
);
const suggestGrading = useCallback(
async (input: GradingInput): Promise<GradingSuggestion | null> => {
setLoading(true);
setError(null);
try {
const result = await aiClient.suggestGrading(input);
if (result.success && result.data) {
return result.data;
}
setError(result.message ?? "AI grading failed");
return null;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return null;
} finally {
setLoading(false);
}
},
[aiClient],
);
return { loading, error, suggestSimilarQuestions, suggestGrading };
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Position } from "./use-position-persistence";
import { clampPosition } from "./use-position-persistence";
type DragState = {
active: boolean;
moved: boolean;
startX: number;
startY: number;
originX: number;
originY: number;
pointerId: number;
};
type DragCallbacks = {
/** 拖拽开始时触发pointer down 后) */
onDragStart: () => void;
/** 拖拽释放时触发moved 表示是否发生了实际移动 */
onRelease: (moved: boolean) => void;
};
/**
* 拖拽位置 Hook
*
* 处理 pointer 事件,跟踪拖拽状态与位置变化。
* 不处理边缘吸附、持久化等业务逻辑,通过回调委托给调用方。
*/
export function useDragPosition(
position: Position,
setPosition: React.Dispatch<React.SetStateAction<Position>>,
callbacks: DragCallbacks,
): {
dragging: boolean;
handlers: {
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void;
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void;
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void;
onPointerCancel: () => void;
};
} {
const [dragging, setDragging] = useState(false);
const dragStateRef = useRef<DragState>({
active: false,
moved: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
pointerId: -1,
});
const callbacksRef = useRef(callbacks);
useEffect(() => {
callbacksRef.current = callbacks;
}, [callbacks]);
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
// 仅主键响应拖拽
if (e.button !== 0 && e.pointerType === "mouse") return;
const s = dragStateRef.current;
s.active = true;
s.moved = false;
s.startX = e.clientX;
s.startY = e.clientY;
s.originX = position.x;
s.originY = position.y;
s.pointerId = e.pointerId;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// ignore
}
setDragging(true);
callbacksRef.current.onDragStart();
},
[position],
);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
const s = dragStateRef.current;
if (!s.active || e.pointerId !== s.pointerId) return;
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
// 阈值过滤微抖动
if (!s.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
s.moved = true;
const next = clampPosition({
x: s.originX + dx,
y: s.originY + dy,
});
setPosition(next);
},
[setPosition],
);
const onPointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
const s = dragStateRef.current;
if (!s.active || e.pointerId !== s.pointerId) return;
s.active = false;
setDragging(false);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
callbacksRef.current.onRelease(s.moved);
},
[],
);
const onPointerCancel = useCallback((): void => {
dragStateRef.current.active = false;
setDragging(false);
}, []);
return {
dragging,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
},
};
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
BALL_SIZE,
HIDE_THRESHOLD,
MARGIN,
type Position,
clampPosition,
getDefaultPosition,
savePosition,
usePositionPersistence,
} from "./use-position-persistence";
import { useDragPosition } from "./use-drag-position";
/**
* 计算吸附到最近边缘后的 X 坐标
*/
function snapToEdge(x: number): number {
if (typeof window === "undefined") return x;
const w = window.innerWidth;
const centerX = x + BALL_SIZE / 2;
const distanceToLeft = centerX;
const distanceToRight = w - centerX;
return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN;
}
/**
* 计算半隐藏时的视觉偏移量
*/
function calculateHiddenOffset(
position: Position,
hidden: boolean,
hovered: boolean,
dragging: boolean,
): number {
if (!hidden || hovered || dragging) return 0;
return position.x <= MARGIN + 2
? -(BALL_SIZE * HIDE_THRESHOLD)
: BALL_SIZE * HIDE_THRESHOLD;
}
/**
* 可拖拽悬浮球 Hook360 悬浮球风格)
*
* 特性:
* - 鼠标/触摸拖拽,松手后吸附到最近屏幕边缘
* - 拖到边缘超过阈值时半隐藏(只露出一小部分)
* - 单击(未发生拖动)触发 onClick
* - 位置持久化到 localStorage
* - 窗口 resize 时自动校正位置
*
* V3拆分为 use-position-persistence + use-drag-position + 本 hook 组合
*/
export function useFloatingBall(onClick: () => void): {
position: Position;
hidden: boolean;
dragging: boolean;
hovered: boolean;
hiddenOffset: number;
handlers: {
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void;
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void;
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void;
onPointerCancel: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
};
show: () => void;
resetPosition: () => void;
} {
const { position, setPosition } = usePositionPersistence();
const [hidden, setHidden] = useState(false);
const [hovered, setHovered] = useState(false);
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
const justHiddenRef = useRef(false);
const onClickRef = useRef(onClick);
useEffect(() => {
onClickRef.current = onClick;
}, [onClick]);
const handleDragStart = useCallback((): void => {
setHidden(false);
}, []);
const handleRelease = useCallback(
(moved: boolean): void => {
// 未移动 → 视为点击
if (!moved) {
onClickRef.current();
return;
}
// 移动了 → 吸附到最近边缘
const snappedX = snapToEdge(position.x);
const finalPos = clampPosition({ x: snappedX, y: position.y });
setPosition(finalPos);
savePosition(finalPos);
setHidden(true);
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
justHiddenRef.current = true;
// 清除 hovered确保 hiddenOffset 生效
setHovered(false);
},
[position, setPosition],
);
const { dragging, handlers: dragHandlers } = useDragPosition(
position,
setPosition,
{ onDragStart: handleDragStart, onRelease: handleRelease },
);
const handleMouseEnter = useCallback((): void => {
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
if (justHiddenRef.current) {
justHiddenRef.current = false;
return;
}
setHovered(true);
if (hidden) setHidden(false);
}, [hidden]);
const handleMouseLeave = useCallback((): void => {
setHovered(false);
// 离开后清除 justHidden 标记,下次进入可正常展开
justHiddenRef.current = false;
}, []);
const show = useCallback((): void => {
justHiddenRef.current = false;
setHidden(false);
}, []);
const resetPosition = useCallback((): void => {
justHiddenRef.current = false;
const fresh = getDefaultPosition();
setPosition(fresh);
savePosition(fresh);
setHidden(false);
}, [setPosition]);
const hiddenOffset = calculateHiddenOffset(
position,
hidden,
hovered,
dragging,
);
return {
position,
hidden,
dragging,
hovered,
hiddenOffset,
handlers: {
...dragHandlers,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
},
show,
resetPosition,
};
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
export type Position = { x: number; y: number };
export const STORAGE_KEY = "ai-widget-position";
export const HIDE_THRESHOLD = 0.55;
export const BALL_SIZE = 56;
export const MARGIN = 16;
/**
* 将位置限制在视口内
*/
export function clampPosition(pos: Position): Position {
if (typeof window === "undefined") return pos;
const maxX = window.innerWidth - BALL_SIZE - MARGIN;
const maxY = window.innerHeight - BALL_SIZE - MARGIN;
return {
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
};
}
/**
* 从 localStorage 加载位置,失败时返回默认右下角位置
*/
export function loadPosition(): Position {
if (typeof window === "undefined") {
return { x: 9999, y: 9999 };
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed: unknown = JSON.parse(raw);
// 从 unknown 转换localStorage 数据不可信,做字段类型校验
if (
typeof parsed === "object" &&
parsed !== null &&
"x" in parsed &&
typeof parsed.x === "number" &&
"y" in parsed &&
typeof parsed.y === "number"
) {
return clampPosition({ x: parsed.x, y: parsed.y });
}
}
} catch {
// ignore
}
const x = window.innerWidth - BALL_SIZE - MARGIN * 2;
const y = window.innerHeight - BALL_SIZE - MARGIN * 4;
return clampPosition({ x, y });
}
/**
* 持久化位置到 localStorage
*/
export function savePosition(pos: Position): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos));
} catch {
// ignore
}
}
/**
* 默认位置(右下角)
*/
export function getDefaultPosition(): Position {
if (typeof window === "undefined") return { x: 9999, y: 9999 };
return clampPosition({
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
});
}
/**
* 位置持久化 Hook
*
* 管理 position 状态mount 时从 localStorage 加载resize 时校正。
* 服务端与客户端首次渲染一致position 在屏幕外),避免 hydration mismatch。
*/
export function usePositionPersistence(): {
position: Position;
setPosition: React.Dispatch<React.SetStateAction<Position>>;
} {
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 });
// 初始化位置:在客户端 mount 后加载真实位置
useEffect(() => {
setPosition(loadPosition());
}, []);
// 窗口 resize 时校正
useEffect(() => {
const handleResize = (): void => {
setPosition((prev) => clampPosition(prev));
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return { position, setPosition };
}

View File

@@ -0,0 +1,280 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// 基础校验
// ---------------------------------------------------------------------------
export const AiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string().min(1).max(8000),
});
export const AiChatInputSchema = z.object({
messages: z.array(AiChatMessageSchema).min(1).max(50),
providerId: z.string().min(1).optional(),
});
// ---------------------------------------------------------------------------
// 业务场景校验
// ---------------------------------------------------------------------------
export const SimilarQuestionInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
subject: z.string().optional(),
knowledgePointIds: z.array(z.string()).optional(),
count: z.number().int().min(1).max(10).optional(),
});
export const GradingInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
studentAnswer: z.string().min(1).max(8000),
correctAnswer: z.string().optional(),
maxScore: z.number().int().min(1).max(100),
subject: z.string().optional(),
});
export const LessonContentInputSchema = z.object({
topic: z.string().min(1).max(500),
subject: z.string().optional(),
grade: z.string().optional(),
textbookId: z.string().optional(),
chapterId: z.string().optional(),
contentType: z.enum(["activity", "assessment", "question", "material"]),
additionalContext: z.string().max(2000).optional(),
});
export const QuestionVariantInputSchema = z.object({
originalQuestion: z.object({
text: z.string().min(1).max(4000),
type: z.string().min(1),
difficulty: z.number().int().min(1).max(5).optional(),
options: z
.array(
z.object({
id: z.string().min(1),
text: z.string().min(1),
isCorrect: z.boolean().optional(),
}),
)
.optional(),
answer: z.string().optional(),
}),
subject: z.string().optional(),
variantType: z.enum([
"same_knowledge_point",
"different_difficulty",
"different_format",
]),
});
export const WeaknessAnalysisInputSchema = z.object({
studentId: z.string().min(1),
subjectId: z.string().optional(),
errorItems: z
.array(
z.object({
questionText: z.string().min(1),
questionType: z.string().min(1),
knowledgePointIds: z.array(z.string()).optional(),
errorCount: z.number().int().min(1),
masteryLevel: z.number().int().min(0).max(5),
}),
)
.min(1)
.max(100),
});
// ---------------------------------------------------------------------------
// AI 返回结果校验(用于解析 AI JSON 输出)
// ---------------------------------------------------------------------------
export const SimilarQuestionResultSchema = z.object({
text: z.string().min(1),
type: z.string().min(1),
difficulty: z.number().int().min(1).max(5).optional(),
options: z.array(z.object({ id: z.string(), text: z.string() })).optional(),
answer: z.string().optional(),
explanation: z.string().optional(),
});
export const SimilarQuestionListSchema = z.array(SimilarQuestionResultSchema);
export const GradingSuggestionSchema = z.object({
suggestedScore: z.number().min(0),
confidence: z.number().min(0).max(1),
feedback: z.string(),
reasoning: z.string(),
});
export const LessonContentResultSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export const QuestionVariantResultSchema = z.object({
text: z.string().min(1),
type: z.string().min(1),
difficulty: z.number().int().min(1).max(5),
options: z
.array(
z.object({ id: z.string(), text: z.string(), isCorrect: z.boolean() }),
)
.optional(),
answer: z.string().optional(),
explanation: z.string().optional(),
});
export const WeaknessAnalysisResultSchema = z.object({
weakAreas: z.array(
z.object({
area: z.string().min(1),
severity: z.enum(["high", "medium", "low"]),
suggestion: z.string().min(1),
}),
),
studyPlan: z.string().min(1),
recommendedResources: z.array(z.string()),
});
// ---------------------------------------------------------------------------
// 家长学情摘要校验
// ---------------------------------------------------------------------------
export const ChildSummaryInputSchema = z.object({
studentId: z.string().min(1),
studentName: z.string().optional(),
grade: z.string().optional(),
recentGrades: z
.array(
z.object({
subject: z.string().min(1),
score: z.number(),
maxScore: z.number(),
trend: z.enum(["up", "down", "stable"]),
}),
)
.optional(),
attendanceRate: z.number().min(0).max(1).optional(),
errorBookSummary: z
.object({
totalErrors: z.number().int().min(0),
topWeakSubjects: z.array(z.string()),
masteryTrend: z.enum(["improving", "declining", "stable"]),
})
.optional(),
homeworkCompletionRate: z.number().min(0).max(1).optional(),
});
export const ChildSummaryResultSchema = z.object({
overallAssessment: z.string().min(1),
strengths: z.array(z.string()),
areasForImprovement: z.array(z.string()),
familyTutoringSuggestions: z.array(z.string()),
nextSteps: z.array(z.string()),
});
// ---------------------------------------------------------------------------
// 学习路径推荐校验
// ---------------------------------------------------------------------------
export const StudyPathInputSchema = z.object({
studentId: z.string().min(1),
subject: z.string().optional(),
currentMastery: z
.array(
z.object({
knowledgePoint: z.string().min(1),
masteryLevel: z.number().min(0).max(5),
errorCount: z.number().int().min(0),
}),
)
.optional(),
learningGoal: z.string().optional(),
/** 教材 ID传入后 action 层自动获取知识图谱注入) */
textbookId: z.string().optional(),
/** 知识图谱(可直接传入,优先于 textbookId 自动获取) */
knowledgeGraph: z
.object({
nodes: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1),
level: z.number().int().min(0),
masteryLevel: z.number().optional(),
}),
),
edges: z.array(
z.object({
from: z.string().min(1),
to: z.string().min(1),
type: z.literal("prerequisite"),
}),
),
})
.optional(),
});
export const StudyPathResultSchema = z.object({
currentLevel: z.string().min(1),
learningPath: z.array(
z.object({
step: z.number().int().min(1),
knowledgePoint: z.string().min(1),
status: z.enum(["mastered", "in_progress", "needs_work"]),
recommendedAction: z.string().min(1),
estimatedTime: z.string().min(1),
}),
),
summary: z.string().min(1),
motivation: z.string().min(1),
});
// ---------------------------------------------------------------------------
// 错题 AI 解释校验
// ---------------------------------------------------------------------------
export const ExplainErrorInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
studentAnswer: z.string().min(1).max(8000),
correctAnswer: z.string().optional(),
subject: z.string().optional(),
knowledgePointIds: z.array(z.string()).optional(),
});
export const ExplainErrorResultSchema = z.object({
errorAnalysis: z.string().min(1),
correctApproach: z.string().min(1),
keyConcepts: z.array(z.string().min(1)),
preventionTips: z.array(z.string().min(1)),
practiceSuggestion: z.string().min(1),
});
// ---------------------------------------------------------------------------
// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON
// ---------------------------------------------------------------------------
export const AiChartSeriesSchema = z.object({
dataKey: z.string().min(1),
name: z.string().min(1),
color: z.string().optional(),
fillOpacity: z.number().optional(),
strokeWidth: z.number().optional(),
strokeDasharray: z.string().optional(),
});
export const AiChartSpecSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
type: z.enum(["bar", "line", "pie", "radar"]).optional(),
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
xKey: z.string().optional(),
angleKey: z.string().optional(),
series: z.array(AiChartSeriesSchema),
yDomain: z.tuple([z.number(), z.number()]).optional(),
height: z.number().optional(),
showLegend: z.boolean().optional(),
});

View File

@@ -0,0 +1,559 @@
/**
* portal-shell 适配env 在客户端微前端不存在env.mjs 为服务端模块),
* 实际 AI 调用由 BFF 处理;此处仅保留默认占位值,避免客户端 bundle 引入服务端依赖。
*/
const env: { AI_MODEL?: string } = { AI_MODEL: "gpt-4o-mini" };
/**
* portal-shell 适配createAiChatCompletion/getAiErrorMessage 在客户端微前端不存在,
* 由 BFF 调用真实 AI SDK此处保留为 stub被调用时抛错提示应通过 BFF 路由。
*/
const createAiChatCompletion = async (
_options: Record<string, unknown>,
): Promise<{ content: string; usage?: unknown }> => {
throw new Error(
"createAiChatCompletion is server-only; portal-shell should call BFF via fetch instead",
);
};
const getAiErrorMessage = (error: unknown): string => {
if (error instanceof Error) return error.message;
return String(error);
};
import {
GRADING_ASSIST_SYSTEM_PROMPT,
LESSON_CONTENT_SYSTEM_PROMPT,
QUESTION_VARIANT_SYSTEM_PROMPT,
SIMILAR_QUESTION_SYSTEM_PROMPT,
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
CHILD_SUMMARY_SYSTEM_PROMPT,
STUDY_PATH_SYSTEM_PROMPT,
EXPLAIN_ERROR_SYSTEM_PROMPT,
} from "./prompt-templates";
import { withAiTracking } from "./usage-tracker";
import {
GradingSuggestionSchema,
LessonContentResultSchema,
QuestionVariantResultSchema,
SimilarQuestionListSchema,
WeaknessAnalysisResultSchema,
ChildSummaryResultSchema,
StudyPathResultSchema,
ExplainErrorResultSchema,
} from "../schema";
import type {
AiChatMessage,
AiChatOptions,
AiChatResult,
AiService,
GradingInput,
GradingSuggestion,
LessonContentInput,
LessonContentResult,
QuestionVariantInput,
QuestionVariantResult,
SimilarQuestionInput,
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
ChildSummaryInput,
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
ExplainErrorInput,
ExplainErrorResult,
} from "../types";
// ---------------------------------------------------------------------------
// JSON 提取工具(从 AI 返回文本中提取 JSON
// ---------------------------------------------------------------------------
const extractBalancedJsonSegment = (value: string): string | null => {
const startBrace = value.indexOf("{");
const startBracket = value.indexOf("[");
const start =
startBrace === -1
? startBracket
: startBracket === -1
? startBrace
: Math.min(startBrace, startBracket);
if (start === -1) return null;
const opening = value[start];
const closing = opening === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < value.length; i += 1) {
const char = value[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === opening) {
depth += 1;
continue;
}
if (char === closing) {
depth -= 1;
if (depth === 0) {
return value.slice(start, i + 1);
}
}
}
return null;
};
const tryParseJson = (value: string): unknown | null => {
try {
return JSON.parse(value);
} catch {
return null;
}
};
const extractJson = (raw: string): unknown => {
const trimmed = raw.trim();
const candidates: string[] = [];
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
if (fencedMatches.length > 0) {
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()));
}
candidates.push(trimmed);
for (const candidate of candidates) {
const direct = tryParseJson(candidate);
if (direct !== null) return direct;
const segment = extractBalancedJsonSegment(candidate);
if (!segment) continue;
const parsed = tryParseJson(segment);
if (parsed !== null) return parsed;
}
throw new Error("Invalid AI response: cannot parse JSON");
};
// ---------------------------------------------------------------------------
// AiService 实现
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = () => String(env.AI_MODEL ?? "gpt-4o-mini");
const buildChatMessages = (
systemPrompt: string,
userContent: string,
): AiChatMessage[] => [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const callAi = async (
messages: AiChatMessage[],
options?: AiChatOptions,
): Promise<{ content: string; model?: string; tokenUsage?: number }> => {
const result = await createAiChatCompletion({
messages,
model: options?.model ?? DEFAULT_MODEL(),
temperature: options?.temperature ?? 0.3,
...(typeof options?.maxTokens === "number"
? { maxTokens: options.maxTokens }
: {}),
...(options?.providerId ? { providerId: options.providerId } : {}),
});
// 从 unknown 类型安全提取 total_tokens避免 as 断言)
const tokenUsage =
result.usage &&
typeof result.usage === "object" &&
"total_tokens" in result.usage
? Number(result.usage.total_tokens ?? 0)
: undefined;
return { content: result.content, tokenUsage };
};
/**
* 默认 AI 服务实现
*
* 封装 shared/lib/ai 的底层 SDK 调用,提供业务语义化接口。
* 所有业务模块通过此服务调用 AI不直接 import shared/lib/ai。
*/
export class DefaultAiService implements AiService {
constructor(private readonly userId: string) {}
async chat(
messages: AiChatMessage[],
options?: AiChatOptions,
): Promise<AiChatResult> {
return withAiTracking(
this.userId,
"chat",
options?.providerId,
async () => {
const { content, tokenUsage } = await callAi(messages, {
...options,
temperature: options?.temperature ?? 0.7,
});
// usage 字段返回 token 用量对象unknown 类型),便于调用方按需类型缩小
return {
result: {
content,
usage:
tokenUsage !== undefined ? { total_tokens: tokenUsage } : null,
},
tokenUsage,
};
},
);
}
async suggestSimilarQuestions(
input: SimilarQuestionInput,
): Promise<SimilarQuestionResult[]> {
return withAiTracking(
this.userId,
"similar_question",
undefined,
async () => {
const count = input.count ?? 3;
const userLines = [
`Question Type: ${input.questionType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.knowledgePointIds?.length
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
: "",
`Generate ${count} similar questions.`,
`Original Question:\n${input.questionText}`,
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(
SIMILAR_QUESTION_SYSTEM_PROMPT,
userLines.join("\n\n"),
),
{ temperature: 0.5, maxTokens: 3000 },
);
const parsed = extractJson(content);
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
const list =
parsed && typeof parsed === "object" && "questions" in parsed
? parsed.questions
: parsed;
const validated = SimilarQuestionListSchema.safeParse(list);
if (!validated.success) return { result: [] };
return { result: validated.data };
},
);
}
async suggestGrading(input: GradingInput): Promise<GradingSuggestion> {
return withAiTracking(
this.userId,
"grading_assist",
undefined,
async () => {
const userLines = [
`Question Type: ${input.questionType}`,
`Max Score: ${input.maxScore}`,
input.subject ? `Subject: ${input.subject}` : "",
`Question:\n${input.questionText}`,
`Student Answer:\n${input.studentAnswer}`,
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(
GRADING_ASSIST_SYSTEM_PROMPT,
userLines.join("\n\n"),
),
{ temperature: 0.2, maxTokens: 1000 },
);
const parsed = extractJson(content);
const validated = GradingSuggestionSchema.safeParse(parsed);
if (!validated.success) {
return {
result: {
suggestedScore: 0,
confidence: 0,
feedback: "AI grading unavailable",
reasoning: "AI response format invalid",
},
};
}
const data = validated.data;
return {
result: {
suggestedScore: Math.min(
Math.max(data.suggestedScore, 0),
input.maxScore,
),
confidence: data.confidence,
feedback: data.feedback,
reasoning: data.reasoning,
},
};
},
);
}
async generateLessonContent(
input: LessonContentInput,
): Promise<LessonContentResult> {
return withAiTracking(
this.userId,
"lesson_content",
undefined,
async () => {
const userLines = [
`Topic: ${input.topic}`,
`Content Type: ${input.contentType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.grade ? `Grade: ${input.grade}` : "",
input.additionalContext
? `Additional Context:\n${input.additionalContext}`
: "",
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(
LESSON_CONTENT_SYSTEM_PROMPT,
userLines.join("\n\n"),
),
{ temperature: 0.7, maxTokens: 4000 },
);
const parsed = extractJson(content);
const validated = LessonContentResultSchema.safeParse(parsed);
if (!validated.success) {
return {
result: {
title: input.topic,
content: content,
},
};
}
return { result: validated.data };
},
);
}
async generateQuestionVariant(
input: QuestionVariantInput,
): Promise<QuestionVariantResult> {
return withAiTracking(
this.userId,
"question_variant",
undefined,
async () => {
const userLines = [
`Variant Type: ${input.variantType}`,
input.subject ? `Subject: ${input.subject}` : "",
`Original Question:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(
QUESTION_VARIANT_SYSTEM_PROMPT,
userLines.join("\n\n"),
),
{ temperature: 0.6, maxTokens: 2000 },
);
const parsed = extractJson(content);
const validated = QuestionVariantResultSchema.safeParse(parsed);
if (!validated.success) {
throw new Error("AI question variant format invalid");
}
return { result: validated.data };
},
);
}
async analyzeWeakness(
input: WeaknessAnalysisInput,
): Promise<WeaknessAnalysisResult> {
return withAiTracking(
this.userId,
"weakness_analysis",
undefined,
async () => {
const userLines = [
`Student ID: ${input.studentId}`,
input.subjectId ? `Subject ID: ${input.subjectId}` : "",
`Error Items (${input.errorItems.length}):`,
JSON.stringify(
input.errorItems.map((item) => ({
questionText: item.questionText,
questionType: item.questionType,
errorCount: item.errorCount,
masteryLevel: item.masteryLevel,
})),
null,
2,
),
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
userLines.join("\n\n"),
),
{ temperature: 0.3, maxTokens: 2000 },
);
const parsed = extractJson(content);
const validated = WeaknessAnalysisResultSchema.safeParse(parsed);
if (!validated.success) {
return {
result: {
weakAreas: [],
studyPlan: "Analysis unavailable",
recommendedResources: [],
},
};
}
return { result: validated.data };
},
);
}
async generateChildSummary(
input: ChildSummaryInput,
): Promise<ChildSummaryResult> {
return withAiTracking(this.userId, "child_summary", undefined, async () => {
// PII 最小化:不传学生真实姓名,用 ID 替代COPPA/FERPA 合规)
const userLines = [
`Student ID: ${input.studentId}`,
input.grade ? `Grade: ${input.grade}` : "",
input.recentGrades && input.recentGrades.length > 0
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
: "",
input.attendanceRate !== undefined
? `Attendance Rate: ${(input.attendanceRate * 100).toFixed(1)}%`
: "",
input.errorBookSummary
? `Error Book Summary:\n${JSON.stringify(input.errorBookSummary, null, 2)}`
: "",
input.homeworkCompletionRate !== undefined
? `Homework Completion Rate: ${(input.homeworkCompletionRate * 100).toFixed(1)}%`
: "",
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(CHILD_SUMMARY_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.4, maxTokens: 2000 },
);
const parsed = extractJson(content);
const validated = ChildSummaryResultSchema.safeParse(parsed);
if (!validated.success) {
return {
result: {
overallAssessment: "Unable to generate summary at this time.",
strengths: [],
areasForImprovement: [],
familyTutoringSuggestions: [],
nextSteps: [],
},
};
}
return { result: validated.data };
});
}
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
return withAiTracking(this.userId, "study_path", undefined, async () => {
const userLines = [
`Student ID: ${input.studentId}`,
input.subject ? `Subject: ${input.subject}` : "",
input.currentMastery && input.currentMastery.length > 0
? `Current Mastery:\n${JSON.stringify(input.currentMastery, null, 2)}`
: "",
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
].filter((line) => line.length > 0);
// 知识图谱上下文注入V3对标 Squirrel AI 纳米级知识图谱)
if (input.knowledgeGraph && input.knowledgeGraph.nodes.length > 0) {
const graphLines = [
"Knowledge Graph:",
"Nodes (id | name | level | mastery 0-100):",
...input.knowledgeGraph.nodes.map(
(n) =>
` ${n.id} | ${n.name} | L${n.level} | ${n.masteryLevel ?? "unassessed"}`,
),
"Prerequisite edges (from -> to, meaning 'from' must be mastered before 'to'):",
...input.knowledgeGraph.edges.map((e) => ` ${e.from} -> ${e.to}`),
];
userLines.push(graphLines.join("\n"));
}
const { content } = await callAi(
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.5, maxTokens: 2000 },
);
const parsed = extractJson(content);
const validated = StudyPathResultSchema.safeParse(parsed);
if (!validated.success) {
return {
result: {
currentLevel: "Analysis unavailable",
learningPath: [],
summary: "Unable to generate learning path at this time.",
motivation: "Keep learning!",
},
};
}
return { result: validated.data };
});
}
async explainError(input: ExplainErrorInput): Promise<ExplainErrorResult> {
return withAiTracking(this.userId, "explain_error", undefined, async () => {
const userLines = [
`Question Type: ${input.questionType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.knowledgePointIds?.length
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
: "",
`Question:\n${input.questionText}`,
`Student Answer:\n${input.studentAnswer}`,
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
].filter((line) => line.length > 0);
const { content } = await callAi(
buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.4, maxTokens: 2000 },
);
const parsed = extractJson(content);
const validated = ExplainErrorResultSchema.safeParse(parsed);
if (!validated.success) {
return {
result: {
errorAnalysis: "Unable to analyze the error at this time.",
correctApproach: "Please consult your teacher for help.",
keyConcepts: [],
preventionTips: [],
practiceSuggestion: "Review the relevant chapter and try again.",
},
};
}
return { result: validated.data };
});
}
}
/**
* 创建 AI 服务实例
*
* 在 Server Action 中调用,传入当前用户 ID。
* 测试时可替换为 mock 实现。
*/
export const createAiService = (userId: string): AiService =>
new DefaultAiService(userId);
/**
* 安全执行 AI 调用,捕获异常并返回错误消息
*/
export const safeAiCall = async <T>(
fn: () => Promise<T>,
): Promise<{ ok: true; data: T } | { ok: false; message: string }> => {
try {
const data = await fn();
return { ok: true, data };
} catch (error) {
return { ok: false, message: getAiErrorMessage(error) };
}
};

View File

@@ -0,0 +1,299 @@
/**
* AI 内容安全过滤
*
* 多层防护:
* 1. 输入过滤:检查用户输入是否包含不当内容
* 2. 输出过滤:检查 AI 回复是否包含不当内容
* 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
*
* 参考 Khanmigo 的多层 moderation 模式。
*
* 注意:当前为内存实现,多实例部署需替换为 RedisINCR + EXPIRE
*/
// ---------------------------------------------------------------------------
// 不当内容关键词(基础过滤,生产环境应接入专业 Moderation API
// ---------------------------------------------------------------------------
const BLOCKED_INPUT_PATTERNS: readonly RegExp[] = [
/\b(violence|kill|murder|suicide|self[- ]?harm|cut myself)\b/i,
/\b(porn|sex|nude|nsfw|explicit)\b/i,
/\b(drug|cocaine|heroin|weed|marijuana)\b/i,
/\b(hack|exploit|malware|virus|phishing)\b/i,
// PII 请求
/\b(your (password|credit card|ssn|social security|bank account))\b/i,
/\b(home address|phone number|real name)\b/i,
];
const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
/\b(violence|kill|murder|suicide|self[- ]?harm)\b/i,
/\b(porn|sex|nude|nsfw|explicit)\b/i,
/\b(drug|cocaine|heroin)\b/i,
];
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
// 学生侧额外限制:禁止直接给出作业答案
/\b(here is the (complete )?answer|the answer is:?)\b/i,
// 强化:匹配"答案是 X" / "正确答案是 X" / "final answer: X"
/\b(the (correct )?answer is\s*[:]?\s*[A-Z\d])/i,
/\bfinal answer[:]\s*\S+/i,
/\b答案(是|应该为|为)\s*[:]?\s*[A-F\d]/i,
];
// ---------------------------------------------------------------------------
// 输入过滤
// ---------------------------------------------------------------------------
export type SafetyFilterResult = {
blocked: boolean;
reason?: string;
};
export const filterUserInput = (
content: string,
options?: { isStudent?: boolean },
): SafetyFilterResult => {
const text = String(content ?? "");
for (const pattern of BLOCKED_INPUT_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "Input contains inappropriate content",
};
}
}
if (options?.isStudent) {
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "Student input blocked by safety filter",
};
}
}
}
return { blocked: false };
};
// ---------------------------------------------------------------------------
// 输出过滤
// ---------------------------------------------------------------------------
export const filterAiOutput = (
content: string,
options?: { isStudent?: boolean },
): SafetyFilterResult => {
const text = String(content ?? "");
for (const pattern of BLOCKED_OUTPUT_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "AI output contains inappropriate content",
};
}
}
if (options?.isStudent) {
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return {
blocked: true,
reason: "AI output blocked for student safety",
};
}
}
}
return { blocked: false };
};
// ---------------------------------------------------------------------------
// 每日限制(原子操作,防 TOCTOU 竞态)
// ---------------------------------------------------------------------------
const DAILY_LIMITS: Record<string, number> = {
student: 50,
teacher: 200,
parent: 30,
admin: 500,
};
export const getDailyLimit = (role: string): number => {
return DAILY_LIMITS[role] ?? 50;
};
/**
* 每日使用计数(内存实现,多实例需替换为 Redis
*
* 注意:当前为单实例内存映射,多实例部署下每个实例独立计数,
* 实际可用次数 = 限额 × 实例数。生产环境应接入 Redis INCR + EXPIRE。
*/
const dailyUsageMap = new Map<string, { date: string; count: number }>();
export const checkDailyLimit = (
userId: string,
role: string,
): SafetyFilterResult => {
const today = new Date().toISOString().slice(0, 10);
const key = `${userId}:${today}`;
const limit = getDailyLimit(role);
const current = dailyUsageMap.get(key);
if (!current) {
return { blocked: false };
}
if (current.count >= limit) {
return {
blocked: true,
reason: `Daily limit reached (${current.count}/${limit})`,
};
}
return { blocked: false };
};
export const incrementDailyUsage = (userId: string): void => {
const today = new Date().toISOString().slice(0, 10);
const key = `${userId}:${today}`;
const current = dailyUsageMap.get(key);
if (current && current.date === today) {
current.count += 1;
} else {
dailyUsageMap.set(key, { date: today, count: 1 });
}
// 清理过期条目(防止内存泄漏)
if (dailyUsageMap.size > 10000) {
for (const [k, v] of dailyUsageMap.entries()) {
if (v.date !== today) {
dailyUsageMap.delete(k);
}
}
}
};
/**
* 原子化检查并递增每日使用计数
*
* 解决 checkDailyLimit + incrementDailyUsage 分离导致的 TOCTOU 竞态:
* 并发请求在限额临界点同时通过检查,导致超额。
*
* 此函数在一次调用内完成「递增 + 判断是否超限」,
* 若递增后超过限额,回滚计数并返回 blocked。
*
* @returns { blocked, currentCount, limit } — blocked 为 true 表示已超限
*/
export const tryConsumeDailyQuota = (
userId: string,
role: string,
): { blocked: boolean; currentCount: number; limit: number } => {
const today = new Date().toISOString().slice(0, 10);
const key = `${userId}:${today}`;
const limit = getDailyLimit(role);
const current = dailyUsageMap.get(key);
// 原子递增
const newCount = current && current.date === today ? current.count + 1 : 1;
dailyUsageMap.set(key, { date: today, count: newCount });
// 清理过期条目
if (dailyUsageMap.size > 10000) {
for (const [k, v] of dailyUsageMap.entries()) {
if (v.date !== today) {
dailyUsageMap.delete(k);
}
}
}
if (newCount > limit) {
// 超限,回滚计数(不惩罚用户因竞态多出的尝试)
dailyUsageMap.set(key, { date: today, count: limit });
return { blocked: true, currentCount: limit, limit };
}
return { blocked: false, currentCount: newCount, limit };
};
/**
* 回退每日使用计数(当 AI 调用失败或内容被过滤时调用)
*
* 确保用户不会因 AI 输出被过滤或调用失败而损失配额。
*/
export const refundDailyQuota = (userId: string): void => {
const today = new Date().toISOString().slice(0, 10);
const key = `${userId}:${today}`;
const current = dailyUsageMap.get(key);
if (current && current.date === today && current.count > 0) {
current.count -= 1;
}
};
// ---------------------------------------------------------------------------
// 苏格拉底式辅导输出校验
// ---------------------------------------------------------------------------
export type SocraticValidationResult = {
valid: boolean;
reason?: string;
};
/**
* 校验 AI 回复是否符合苏格拉底式辅导原则
*
* 规则:
* 1. 回复必须以问号结尾(中英文均可)
* 2. 不得包含超过 2 句连续陈述句而不提问
* 3. 不得直接给出最终答案(复用 STUDENT_BLOCKED_PATTERNS
*
* 用于学生侧 AI 对话,强制引导式教学。
*/
export const validateSocraticOutput = (
content: string,
): SocraticValidationResult => {
const text = String(content ?? "").trim();
if (!text) {
return { valid: false, reason: "Empty response" };
}
// 检查是否直接给出答案
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return { valid: false, reason: "Response contains direct answer" };
}
}
// 检查是否以问号结尾
if (!/[?]$/.test(text)) {
return { valid: false, reason: "Response must end with a question" };
}
// 检查连续陈述句数量(按句号/感叹号分割)
const sentences = text
.split(/[。!?.!?]/)
.filter((s) => s.trim().length > 0);
let consecutiveStatements = 0;
for (const sentence of sentences) {
// 如果句子本身是疑问句(包含 ? 或 ?),重置计数
if (/[?]/.test(sentence)) {
consecutiveStatements = 0;
} else {
consecutiveStatements += 1;
if (consecutiveStatements > 2) {
return {
valid: false,
reason: "Too many consecutive statements without a question",
};
}
}
}
return { valid: true };
};

View File

@@ -0,0 +1,308 @@
/**
* AI Prompt 模板
*
* 从 CICD src/modules/ai/services/prompt-templates.ts 迁移。
* 集中管理所有业务场景的 Prompt便于版本管理与调优。
* 所有 Prompt 使用英文以获得最佳模型兼容性,业务文本通过 user message 注入。
*
* 适配说明portal-shell 为客户端微前端,本文件为纯常量定义,
* 可在客户端安全引用(实际由 BFF/服务端使用)。
*/
// ---------------------------------------------------------------------------
// 相似题推荐
// ---------------------------------------------------------------------------
export const SIMILAR_QUESTION_SYSTEM_PROMPT = [
"You are an expert K12 education question generator.",
"Given a question, generate similar practice questions that test the same knowledge points.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "questions": [',
" {",
' "text": "question text",',
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 3,',
' "options": [{ "id": "A", "text": "option text" }],',
' "answer": "correct answer",',
' "explanation": "brief explanation"',
" }",
" ]",
"}",
"Rules:",
"- Generate 1-5 similar questions based on the count parameter.",
"- Keep the same knowledge points but vary the context and numbers.",
"- For choice questions, always include 4 options.",
"- For text questions, omit options and include the answer.",
"- Difficulty should be 1-5, matching the original.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n");
// ---------------------------------------------------------------------------
// AI 辅助批改
// ---------------------------------------------------------------------------
export const GRADING_ASSIST_SYSTEM_PROMPT = [
"You are an expert K12 teacher assistant for grading subjective questions.",
"Given a question, the student's answer, and the correct answer (if available),",
"evaluate the student's answer and suggest a score with feedback.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "suggestedScore": 4,',
' "confidence": 0.85,',
' "feedback": "constructive feedback in the student\'s language",',
' "reasoning": "why this score was assigned"',
"}",
"Rules:",
"- suggestedScore must be between 0 and maxScore.",
"- confidence is between 0 and 1 (higher means more certain).",
"- feedback should be encouraging and specific.",
"- If the answer is completely wrong, suggestedScore should be 0.",
"- If the answer is partially correct, give partial credit.",
"- Consider alternative correct answers if the question allows.",
"Never output placeholders.",
].join("\n");
// ---------------------------------------------------------------------------
// 备课内容生成
// ---------------------------------------------------------------------------
export const LESSON_CONTENT_SYSTEM_PROMPT = [
"You are an expert K12 instructional designer.",
"Generate teaching content based on the given topic and context.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "title": "content title",',
' "content": "detailed content in markdown format",',
' "metadata": { "duration": "15 min", "materials": ["..."] }',
"}",
"Rules:",
"- Content should be age-appropriate for the specified grade.",
"- For 'activity' type: generate an interactive classroom activity.",
"- For 'assessment' type: generate a formative assessment.",
"- For 'question' type: generate discussion questions.",
"- For 'material' type: generate teaching material outline.",
"- Content should align with the subject curriculum.",
"Never output placeholders.",
].join("\n");
// ---------------------------------------------------------------------------
// 题目变体生成
// ---------------------------------------------------------------------------
export const QUESTION_VARIANT_SYSTEM_PROMPT = [
"You are an expert K12 question variation generator.",
"Given an original question, generate a variant based on the specified type.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "text": "variant question text",',
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 3,',
' "options": [{ "id": "A", "text": "option", "isCorrect": true }],',
' "answer": "correct answer",',
' "explanation": "brief explanation"',
"}",
"Variant types:",
"- same_knowledge_point: test the same concept with different context.",
"- different_difficulty: make it easier or harder.",
"- different_format: change the question type (e.g., choice to text).",
"Rules:",
"- For choice questions, always include 4 options with exactly one correct.",
"- Difficulty must be 1-5.",
"Never output placeholders.",
].join("\n");
// ---------------------------------------------------------------------------
// 薄弱点分析
// ---------------------------------------------------------------------------
export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
"You are an expert K12 learning analyst.",
"Analyze the student's error patterns and identify weak areas.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "weakAreas": [',
" {",
' "area": "knowledge area name",',
' "severity": "high | medium | low",',
' "rootCause": "underlying reason, e.g. missing prerequisite",',
' "suggestion": "specific improvement suggestion"',
" }",
" ],",
' "studyPlan": "personalized study plan summary",',
' "recommendedResources": ["resource 1", "resource 2"]',
"}",
"Rules:",
"- Identify 2-5 weak areas based on error frequency and mastery level.",
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
"- If prerequisite knowledge is provided and a prerequisite mastery < 2, list the prerequisite as the rootCause.",
"- Suggestions should be actionable and specific.",
"- Study plan should be concise (3-5 sentences).",
"- Recommended resources can be topic names or study strategies.",
"Never output placeholders.",
].join("\n");
// ---------------------------------------------------------------------------
// 通用 JSON 提取提示词(用于修复 AI 返回的无效 JSON
// ---------------------------------------------------------------------------
export const JSON_REPAIR_SYSTEM_PROMPT = [
"You are a JSON repair engine.",
"Fix the provided invalid JSON into valid JSON only.",
"Keep the original structure and values as much as possible.",
"Do not use placeholders such as ... or [...].",
"Return JSON only without markdown.",
].join("\n");
// ---------------------------------------------------------------------------
// 通用聊天(全局 AI 助手)
// ---------------------------------------------------------------------------
export const CHAT_SYSTEM_PROMPT = [
"You are a helpful K12 education assistant for the Next_Edu school management system.",
"You assist teachers, students, parents, and administrators with their daily tasks.",
"Respond in the user's language (Chinese by default).",
"Use Markdown formatting for structured content (lists, tables, code blocks).",
"Be concise, accurate, and pedagogically sound.",
"",
"## Data Visualization",
"When presenting quantitative data, trends, comparisons, or distributions, ALWAYS render a chart using a fenced code block with one of these languages:",
"- ```chart:bar — for comparing categories or showing distributions",
"- ```chart:line — for trends over time",
"- ```chart:pie — for part-to-whole / percentage breakdown",
"- ```chart:radar — for multi-dimensional comparison (e.g. subject mastery)",
"",
"Chart spec format (JSON inside the code block):",
"```",
"{",
' "title": "图表标题",',
' "description": "可选说明",',
' "data": [',
' { "name": "数学", "score": 85, "fullTitle": "数学科目" }',
" ],",
' "xKey": "name", // bar/line: X 轴字段pie: nameKey',
' "angleKey": "name", // radar: 角度轴字段(可选,默认同 xKey',
' "series": [',
' { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }',
" ],",
' "yDomain": [0, 100], // 可选Y 轴范围',
' "height": 280, // 可选,高度 px',
' "showLegend": true // 可选,多系列时建议 true',
"}",
"```",
"",
"Chart rules:",
"- Only use charts when data is genuinely quantitative (numbers, percentages, counts).",
"- Keep data arrays small (≤ 12 items) for readability.",
'- For pie charts, each data item must have a `name` and a `value` field; set series[0].dataKey to "value".',
"- For radar charts, set `angleKey` to the dimension name field and provide one series per metric.",
"- Colors are optional; if omitted, a colorblind-friendly palette is applied.",
"- Always provide a concise text explanation alongside the chart.",
"- Do NOT wrap the JSON in any other markdown; output the raw JSON inside the fenced block.",
].join("\n");
// ---------------------------------------------------------------------------
// 苏格拉底式辅导(学生专用,强制引导式教学)
// ---------------------------------------------------------------------------
export const SOCRATIC_TUTOR_SYSTEM_PROMPT = [
"You are a Socratic tutor for K12 students.",
"STRICT RULES (never violate):",
"- NEVER output the final answer directly.",
"- NEVER output more than 2 consecutive sentences without asking a question.",
"- Use a 3-tier hint escalation: Tier 1 (conceptual question) → Tier 2 (concrete hint) → Tier 3 (worked example without the final step).",
"- If the student asks for the answer 3+ times, explain why guided discovery is better for learning.",
"- Always end your response with a question that moves the student forward.",
"- Track the student's reasoning and point out the exact step where they went wrong.",
"- Respond in the student's language (Chinese by default).",
].join("\n");
// ---------------------------------------------------------------------------
// 家长学情摘要
// ---------------------------------------------------------------------------
export const CHILD_SUMMARY_SYSTEM_PROMPT = [
"You are an expert K12 family education advisor.",
"Analyze the student's learning data and generate a summary for parents.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "overallAssessment": "brief overall assessment in parent-friendly language",',
' "strengths": ["strength 1", "strength 2"],',
' "areasForImprovement": ["area 1", "area 2"],',
' "familyTutoringSuggestions": ["suggestion 1", "suggestion 2"],',
' "nextSteps": ["actionable next step 1", "actionable next step 2"]',
"}",
"Rules:",
"- Use encouraging and constructive tone.",
"- Focus on actionable advice parents can follow at home.",
"- Avoid educational jargon; use plain language.",
"- Consider cultural sensitivity in family education.",
"- If data is limited, provide general guidance.",
"Never output placeholders.",
].join("\n");
// ---------------------------------------------------------------------------
// 学习路径推荐
// ---------------------------------------------------------------------------
export const STUDY_PATH_SYSTEM_PROMPT = [
"You are an expert K12 adaptive learning path designer.",
"Based on the student's current mastery levels and knowledge graph, recommend a personalized learning path.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "currentLevel": "brief description of current level",',
' "learningPath": [',
" {",
' "step": 1,',
' "knowledgePoint": "knowledge point name",',
' "status": "mastered | in_progress | needs_work",',
' "recommendedAction": "specific action to take",',
' "estimatedTime": "15 min"',
" }",
" ],",
' "summary": "brief summary of the learning path",',
' "motivation": "encouraging message for the student"',
"}",
"Rules:",
"- Order learning path from foundational to advanced.",
"- MUST follow prerequisite chains: if a knowledge point has unmastered prerequisites, list the prerequisites first.",
"- Prioritize weak areas (mastery < 2) first, but only after their prerequisites are addressed.",
"- Include 3-7 steps in the learning path.",
"- estimatedTime should be realistic (5-30 min per step).",
"- motivation should be age-appropriate and encouraging.",
"Never output placeholders.",
].join("\n");
// ---------------------------------------------------------------------------
// 错题 AI 解释
// ---------------------------------------------------------------------------
export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
"You are an expert K12 tutor specializing in helping students understand their mistakes.",
"Analyze the student's error and provide a clear, encouraging explanation.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "errorAnalysis": "detailed analysis of why the student made this error",',
' "correctApproach": "step-by-step correct solution approach",',
' "keyConcepts": ["list of key concepts the student needs to review"],',
' "preventionTips": ["tips to avoid similar mistakes in the future"],',
' "practiceSuggestion": "specific practice recommendation"',
"}",
"Rules:",
"- Use age-appropriate language for K12 students.",
"- Be encouraging and constructive, never dismissive.",
"- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
"- correctApproach should be step-by-step and easy to follow.",
"- keyConcepts should list 2-5 fundamental concepts.",
"- preventionTips should be actionable and specific.",
"- practiceSuggestion should recommend a specific type of practice problem.",
"Never output placeholders.",
].join("\n");

View File

@@ -0,0 +1,132 @@
/**
* portal-shell 适配trackEvent/EventName 在客户端微前端不存在,
* 服务端埋点由 BFF 负责;此处保留为 no-op 占位以保持类型契约。
*/
type EventName = string;
const trackEvent = async (_event: {
event: EventName;
userId: string;
targetType?: string;
properties?: Record<string, unknown>;
}): Promise<void> => {
/* no-op: portal-shell client-side stub */
};
/**
* portal-shell 适配data-access/recordAiEvent 在客户端微前端不存在,
* 由 BFF 维护事件存储;此处保留为 no-op 占位。
*/
const recordAiEvent = (_event: {
userId: string;
capability: string;
success: boolean;
durationMs: number;
timestamp: number;
errorMessage?: string;
}): void => {
/* no-op: portal-shell client-side stub */
};
export type AiUsageEvent = {
userId: string;
capability:
| "chat"
| "similar_question"
| "grading_assist"
| "lesson_content"
| "question_variant"
| "weakness_analysis"
| "child_summary"
| "study_path"
| "explain_error";
providerId?: string;
model?: string;
success: boolean;
durationMs: number;
tokenUsage?: number;
errorMessage?: string;
};
const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
chat: "ai.chat",
similar_question: "ai.similar_question",
grading_assist: "ai.grading_assist",
lesson_content: "ai.lesson_content",
question_variant: "ai.question_variant",
weakness_analysis: "ai.weakness_analysis",
child_summary: "ai.child_summary",
study_path: "ai.study_path",
explain_error: "ai.explain_error",
};
/**
* AI 使用埋点
*
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
* 同时写入 data-access 层的内存事件存储(供管理员仪表盘聚合查询)。
* 非阻塞,失败不影响主流程。
*/
export const trackAiUsage = (event: AiUsageEvent): void => {
const eventName = AI_EVENT_MAP[event.capability];
// 写入 data-access 层(供 getAiUsageStats 聚合)
recordAiEvent({
userId: event.userId,
capability: event.capability,
success: event.success,
durationMs: event.durationMs,
timestamp: Date.now(),
errorMessage: event.errorMessage,
});
// 写入全局 trackEvent供外部监控系统
void trackEvent({
event: eventName,
userId: event.userId,
targetType: event.capability,
properties: {
providerId: event.providerId,
model: event.model,
success: event.success,
durationMs: event.durationMs,
tokenUsage: event.tokenUsage,
errorMessage: event.errorMessage,
},
}).catch(() => {
// 静默失败:埋点不应影响业务流程
});
};
/**
* 测量 AI 调用耗时并自动埋点
*/
export const withAiTracking = async <T>(
userId: string,
capability: AiUsageEvent["capability"],
providerId: string | undefined,
fn: () => Promise<{ result: T; model?: string; tokenUsage?: number }>,
): Promise<T> => {
const start = Date.now();
try {
const { result, model, tokenUsage } = await fn();
trackAiUsage({
userId,
capability,
providerId,
model,
success: true,
durationMs: Date.now() - start,
tokenUsage,
});
return result;
} catch (error) {
trackAiUsage({
userId,
capability,
providerId,
success: false,
durationMs: Date.now() - start,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
};

View File

@@ -0,0 +1,332 @@
/**
* AI 模块类型定义
*
* 从 CICD src/modules/ai/types.ts 迁移。
* 适配ActionState 在 portal-shell 未提供,本文件内联定义。
*/
// ---------------------------------------------------------------------------
// ActionStateportal-shell 未提供,本模块内联;结构与 CICD @/shared/types/action-state 一致)
// ---------------------------------------------------------------------------
export type ActionState<T> = {
success: boolean;
data?: T;
message?: string;
};
// ---------------------------------------------------------------------------
// 基础类型
// ---------------------------------------------------------------------------
export type AiChatRole = "system" | "user" | "assistant";
export type AiChatMessage = {
role: AiChatRole;
content: string;
};
export type AiChatOptions = {
providerId?: string;
temperature?: number;
maxTokens?: number;
model?: string;
};
export type AiChatResult = {
content: string;
usage: unknown;
};
// ---------------------------------------------------------------------------
// 业务场景类型
// ---------------------------------------------------------------------------
export type SimilarQuestionInput = {
questionText: string;
questionType: string;
subject?: string;
knowledgePointIds?: string[];
count?: number;
};
export type SimilarQuestionResult = {
text: string;
type: string;
difficulty?: number;
options?: Array<{ id: string; text: string }>;
answer?: string;
explanation?: string;
};
export type GradingInput = {
questionText: string;
questionType: string;
studentAnswer: string;
correctAnswer?: string;
maxScore: number;
subject?: string;
};
export type GradingSuggestion = {
suggestedScore: number;
confidence: number;
feedback: string;
reasoning: string;
};
export type LessonContentInput = {
topic: string;
subject?: string;
grade?: string;
textbookId?: string;
chapterId?: string;
contentType: "activity" | "assessment" | "question" | "material";
additionalContext?: string;
};
export type LessonContentResult = {
title: string;
content: string;
metadata?: Record<string, unknown>;
};
export type QuestionVariantInput = {
originalQuestion: {
text: string;
type: string;
difficulty?: number;
options?: Array<{ id: string; text: string; isCorrect?: boolean }>;
answer?: string;
};
subject?: string;
variantType:
"same_knowledge_point" | "different_difficulty" | "different_format";
};
export type QuestionVariantResult = {
text: string;
type: string;
difficulty: number;
options?: Array<{ id: string; text: string; isCorrect: boolean }>;
answer?: string;
explanation?: string;
};
export type WeaknessAnalysisInput = {
studentId: string;
subjectId?: string;
errorItems: Array<{
questionText: string;
questionType: string;
knowledgePointIds?: string[];
errorCount: number;
masteryLevel: number;
}>;
};
export type WeaknessAnalysisResult = {
weakAreas: Array<{
area: string;
severity: "high" | "medium" | "low";
suggestion: string;
}>;
studyPlan: string;
recommendedResources: string[];
};
// ---------------------------------------------------------------------------
// 家长学情摘要类型
// ---------------------------------------------------------------------------
export type ChildSummaryInput = {
studentId: string;
studentName?: string;
grade?: string;
recentGrades?: Array<{
subject: string;
score: number;
maxScore: number;
trend: "up" | "down" | "stable";
}>;
attendanceRate?: number;
errorBookSummary?: {
totalErrors: number;
topWeakSubjects: string[];
masteryTrend: "improving" | "declining" | "stable";
};
homeworkCompletionRate?: number;
};
export type ChildSummaryResult = {
overallAssessment: string;
strengths: string[];
areasForImprovement: string[];
familyTutoringSuggestions: string[];
nextSteps: string[];
};
// ---------------------------------------------------------------------------
// 学习路径推荐类型
// ---------------------------------------------------------------------------
export type StudyPathInput = {
studentId: string;
subject?: string;
currentMastery?: Array<{
knowledgePoint: string;
masteryLevel: number;
errorCount: number;
}>;
learningGoal?: string;
/** 教材 ID传入后 action 层自动获取知识图谱注入) */
textbookId?: string;
/** 知识图谱(可直接传入,优先于 textbookId 自动获取) */
knowledgeGraph?: {
nodes: Array<{
id: string;
name: string;
level: number;
masteryLevel?: number;
}>;
edges: Array<{
from: string;
to: string;
type: "prerequisite";
}>;
};
};
export type StudyPathResult = {
currentLevel: string;
learningPath: Array<{
step: number;
knowledgePoint: string;
status: "mastered" | "in_progress" | "needs_work";
recommendedAction: string;
estimatedTime: string;
}>;
summary: string;
motivation: string;
};
// ---------------------------------------------------------------------------
// 错题 AI 解释类型
// ---------------------------------------------------------------------------
export type ExplainErrorInput = {
questionText: string;
questionType: string;
studentAnswer: string;
correctAnswer?: string;
subject?: string;
knowledgePointIds?: string[];
};
export type ExplainErrorResult = {
errorAnalysis: string;
correctApproach: string;
keyConcepts: string[];
preventionTips: string[];
practiceSuggestion: string;
};
export type AiUsageStats = {
totalCalls: number;
callsToday: number;
callsThisWeek: number;
activeUsers: number;
errorRate: number;
avgDurationMs: number;
byCapability: Array<{ capability: string; count: number }>;
byRole: Array<{ role: string; count: number }>;
topUsers: Array<{ userId: string; count: number }>;
recentActivity: Array<{
userId: string;
capability: string;
success: boolean;
durationMs: number;
timestamp: string;
}>;
};
export type AiCapability =
| "chat"
| "exam-generate"
| "grading-assist"
| "lesson-content"
| "question-variant"
| "similar-question"
| "weakness-analysis"
| "usage-stats";
// ---------------------------------------------------------------------------
// 服务端 AI 服务接口DefaultAiService 实现于此)
// ---------------------------------------------------------------------------
/**
* 服务端 AI 服务接口
*
* portal-shell 为客户端微前端,真实实现位于 BFF/服务端。
* 此接口仅用于类型契约;客户端组件通过 AiClientServicefetch BFF调用。
*/
export interface AiService {
chat: (
messages: AiChatMessage[],
options?: AiChatOptions,
) => Promise<AiChatResult>;
suggestSimilarQuestions: (
input: SimilarQuestionInput,
) => Promise<SimilarQuestionResult[]>;
suggestGrading: (input: GradingInput) => Promise<GradingSuggestion>;
generateLessonContent: (
input: LessonContentInput,
) => Promise<LessonContentResult>;
generateQuestionVariant: (
input: QuestionVariantInput,
) => Promise<QuestionVariantResult>;
analyzeWeakness: (
input: WeaknessAnalysisInput,
) => Promise<WeaknessAnalysisResult>;
summarizeChild?: (input: ChildSummaryInput) => Promise<ChildSummaryResult>;
recommendStudyPath: (input: StudyPathInput) => Promise<StudyPathResult>;
explainError: (input: ExplainErrorInput) => Promise<ExplainErrorResult>;
}
// ---------------------------------------------------------------------------
// 客户端 AI 服务接口
// ---------------------------------------------------------------------------
/**
* AI 客户端服务接口
*
* portal-shell 为客户端微前端,服务方法通过 fetch 调用 BFF/API 路由,
* 返回 ActionState<T>(与 CICD Server Action 模式结构一致)。
* 通过 React Context 注入,组件经 useAiClient() 消费。
*/
export interface AiClientService {
chat: (input: {
messages: AiChatMessage[];
providerId?: string;
}) => Promise<ActionState<AiChatResult>>;
suggestSimilarQuestions: (
input: SimilarQuestionInput,
) => Promise<ActionState<SimilarQuestionResult[]>>;
suggestGrading: (
input: GradingInput,
) => Promise<ActionState<GradingSuggestion>>;
generateLessonContent: (
input: LessonContentInput,
) => Promise<ActionState<LessonContentResult>>;
generateQuestionVariant: (
input: QuestionVariantInput,
) => Promise<ActionState<QuestionVariantResult>>;
analyzeWeakness: (
input: WeaknessAnalysisInput,
) => Promise<ActionState<WeaknessAnalysisResult>>;
explainError?: (
input: ExplainErrorInput,
) => Promise<ActionState<ExplainErrorResult>>;
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>;
trackEvent?: (event: string, payload?: Record<string, unknown>) => void;
}

View File

@@ -0,0 +1,373 @@
"use client";
import { useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
CartesianGrid,
Scatter,
ScatterChart,
XAxis,
YAxis,
ZAxis,
Cell,
} from "recharts";
import { TrendingDown, AlertTriangle, CheckCircle2, Inbox } from "lucide-react";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/charts/chart";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { cn } from "@/shared/lib/utils";
import type {
AttendanceGradeCorrelationSummary,
AttendanceGradeRiskLevel,
} from "../types";
const SCATTER_MARGIN = { top: 16, right: 16, bottom: 32, left: 16 };
const SCATTER_GRID_PROPS = { strokeDasharray: "4 4", strokeOpacity: 0.4 };
const TOOLTIP_CURSOR = { strokeDasharray: "3 3" };
const Z_AXIS_RANGE: [number, number] = [60, 60];
const AXIS_LABEL_STYLE = { fontSize: 12, fill: "hsl(var(--muted-foreground))" };
const SCATTER_DOMAIN: [number, number] = [0, 100];
const RISK_COLORS: Record<AttendanceGradeRiskLevel, string> = {
high: "hsl(var(--chart-1))",
medium: "hsl(var(--chart-4))",
low: "hsl(var(--chart-2))",
};
const RISK_BADGE_VARIANTS: Record<
AttendanceGradeRiskLevel,
"destructive" | "default" | "secondary"
> = {
high: "destructive",
medium: "default",
low: "secondary",
};
const RISK_ICONS: Record<AttendanceGradeRiskLevel, typeof AlertTriangle> = {
high: TrendingDown,
medium: AlertTriangle,
low: CheckCircle2,
};
const RISK_ICON_CLASSNAMES: Record<AttendanceGradeRiskLevel, string> = {
high: "text-red-500",
medium: "text-amber-500",
low: "text-emerald-500",
};
const chartConfig: ChartConfig = {
students: {
label: "Students",
},
};
interface ScatterDataItem {
studentId: string;
studentName: string;
attendanceRate: number;
averageScore: number;
riskLevel: AttendanceGradeRiskLevel;
attendanceRecordCount: number;
gradeRecordCount: number;
absentCount: number;
}
export function AttendanceGradeCorrelationCard({
summary,
}: {
summary: AttendanceGradeCorrelationSummary | null;
}): React.ReactElement {
const t = useTranslations("attendance");
const scatterData = useMemo<ScatterDataItem[]>(() => {
if (!summary) return [];
return summary.items.map((it) => ({
studentId: it.studentId,
studentName: it.studentName,
attendanceRate: it.attendanceRate,
averageScore: it.averageScore,
riskLevel: it.riskLevel,
attendanceRecordCount: it.attendanceRecordCount,
gradeRecordCount: it.gradeRecordCount,
absentCount: it.absentCount,
}));
}, [summary]);
const renderTooltip = useCallback(
(payload: unknown): React.ReactNode => {
const item = payload as { payload?: unknown } | undefined;
const data = item?.payload as ScatterDataItem | undefined;
if (!data) return null;
return (
<div className="space-y-1">
<div className="font-medium">{data.studentName}</div>
<div className="text-xs text-muted-foreground">
{t("correlation.attendanceRate")}: {data.attendanceRate}%
</div>
<div className="text-xs text-muted-foreground">
{t("correlation.averageScore")}: {data.averageScore}
</div>
<div className="text-xs text-muted-foreground">
{t("correlation.absentCount")}: {data.absentCount}
</div>
</div>
);
},
[t],
);
if (!summary) {
return (
<Card>
<CardHeader>
<CardTitle>{t("correlation.title")}</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={Inbox}
title={t("correlation.noData")}
description={t("correlation.noDataDescription")}
className="h-auto border-none shadow-none"
/>
</CardContent>
</Card>
);
}
const interpretationLabel = t(
`correlation.interpretation.${summary.correlationInterpretation}`,
);
return (
<Card>
<CardHeader>
<CardTitle>{t("correlation.title")}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("correlation.description", {
className: summary.className,
start: summary.startDate,
end: summary.endDate,
})}
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<SummaryStat
label={t("correlation.correlationCoefficient")}
value={
summary.correlation !== null
? summary.correlation.toFixed(4)
: t("correlation.notAvailable")
}
sublabel={interpretationLabel}
/>
<SummaryStat
label={t("correlation.riskCounts.high")}
value={String(summary.riskCounts.high)}
icon={TrendingDown}
iconClassName={RISK_ICON_CLASSNAMES.high}
/>
<SummaryStat
label={t("correlation.riskCounts.medium")}
value={String(summary.riskCounts.medium)}
icon={AlertTriangle}
iconClassName={RISK_ICON_CLASSNAMES.medium}
/>
<SummaryStat
label={t("correlation.riskCounts.low")}
value={String(summary.riskCounts.low)}
icon={CheckCircle2}
iconClassName={RISK_ICON_CLASSNAMES.low}
/>
</div>
{/* Scatter chart */}
{scatterData.length > 0 ? (
<div>
<h4 className="mb-3 text-sm font-medium">
{t("correlation.scatterTitle")}
</h4>
<ChartContainer config={chartConfig} className="h-[320px] w-full">
<ScatterChart margin={SCATTER_MARGIN}>
<CartesianGrid {...SCATTER_GRID_PROPS} />
<XAxis
type="number"
dataKey="attendanceRate"
name={t("correlation.attendanceRate")}
domain={SCATTER_DOMAIN}
tickLine={false}
axisLine={false}
tickMargin={8}
label={{
value: t("correlation.attendanceRate"),
position: "bottom",
offset: 16,
style: AXIS_LABEL_STYLE,
}}
/>
<YAxis
type="number"
dataKey="averageScore"
name={t("correlation.averageScore")}
domain={SCATTER_DOMAIN}
tickLine={false}
axisLine={false}
width={40}
/>
<ZAxis type="number" range={Z_AXIS_RANGE} />
<ChartTooltip
cursor={TOOLTIP_CURSOR}
content={
<ChartTooltipContent hideLabel formatter={renderTooltip} />
}
/>
<Scatter
name={t("correlation.scatterSeries")}
data={scatterData}
shape="circle"
>
{scatterData.map((entry) => (
<Cell
key={entry.studentId}
fill={RISK_COLORS[entry.riskLevel]}
fillOpacity={0.7}
/>
))}
</Scatter>
</ScatterChart>
</ChartContainer>
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs">
{(["high", "medium", "low"] as const).map((level) => (
<div key={level} className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: RISK_COLORS[level] }}
aria-hidden="true"
/>
<span className="text-muted-foreground">
{t("correlation.riskLevel.")}
</span>
</div>
))}
</div>
</div>
) : (
<EmptyState
icon={Inbox}
title={t("correlation.noStudents")}
description={t("correlation.noStudentsDescription")}
className="h-auto border-none shadow-none"
/>
)}
{summary.items.length > 0 && (
<div>
<h4 className="mb-3 text-sm font-medium">
{t("correlation.studentDetails")}
</h4>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead className="text-right">
{t("correlation.attendanceRate")}
</TableHead>
<TableHead className="text-right">
{t("correlation.absentCount")}
</TableHead>
<TableHead className="text-right">
{t("correlation.averageScore")}
</TableHead>
<TableHead className="text-right">
{t("correlation.gradeRecordCount")}
</TableHead>
<TableHead>{t("correlation.riskLevelLabel")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.items.map((item) => {
const Icon = RISK_ICONS[item.riskLevel];
return (
<TableRow key={item.studentId}>
<TableCell className="font-medium">
{item.studentName}
</TableCell>
<TableCell className="text-right tabular-nums">
{item.attendanceRate}%
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{item.absentCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{item.averageScore}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{item.gradeRecordCount}
</TableCell>
<TableCell>
<Badge variant={RISK_BADGE_VARIANTS[item.riskLevel]}>
<Icon className="mr-1 h-3 w-3" aria-hidden="true" />
{t("correlation.riskLevel.")}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
</Card>
);
}
function SummaryStat({
label,
value,
sublabel,
icon: Icon,
iconClassName,
}: {
label: string;
value: string;
sublabel?: string;
icon?: typeof AlertTriangle;
iconClassName?: string;
}): React.ReactElement {
return (
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{label}</p>
{Icon && (
<Icon className={cn("h-4 w-4", iconClassName)} aria-hidden="true" />
)}
</div>
<p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
{sublabel && (
<p className="mt-1 text-xs text-muted-foreground">{sublabel}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/utils";
/**
* 考勤模块页面布局(消除页面重复结构)。
*
* 复用模式:标题区 + 统计卡片(可选)+ 筛选区 + 内容区。
*/
interface AttendancePageLayoutProps {
/** 页面头部 */
header: ReactNode;
/** 统计卡片区 */
stats?: ReactNode;
/** 筛选区 */
filters?: ReactNode;
/** 主体内容 */
children: ReactNode;
/** 额外类名 */
className?: string;
}
export function AttendancePageLayout({
header,
stats,
filters,
children,
className,
}: AttendancePageLayoutProps): React.ReactElement {
return (
<div
className={cn("h-full flex-1 flex-col space-y-8 p-8 md:flex", className)}
>
{header}
{stats}
{filters}
{children}
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
/**
* 考勤记录列表组件ARCHITECTURE.md §9.1 教师域考勤模块)
*
* 从 CICD attendance-record-list.tsx 迁移,适配 portal-shell
* - 数据AttendanceRecord 类型从 @/lib/api 引入
* - 操作onDelete 回调替代 Server Action deleteAttendanceAction
* - 状态4 态present/absent/late/leave对齐 portal-shell ATTENDANCE_STATUS
* - 样式attendanceStatusToBadgeClass 替代 ATTENDANCE_STATUS_BADGE_VARIANTS
*
* 关联ARCHITECTURE.md §5.3 / §9.1 / project_rules §3.1
*/
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Trash2, Inbox } from "lucide-react";
import type { AttendanceRecord } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { usePermission } from "@/shared/hooks/use-permission";
import {
attendanceStatusToBadgeClass,
formatAttendanceDate,
formatAttendanceStatus,
} from "../transformations";
interface AttendanceRecordListProps {
records: AttendanceRecord[];
onDelete?: (id: string) => Promise<void> | void;
canDelete?: boolean;
}
export function AttendanceRecordList({
records,
onDelete,
canDelete = false,
}: AttendanceRecordListProps): React.ReactElement {
const t = useTranslations("attendance");
const { hasPermission } = usePermission();
const canManage = canDelete && hasPermission("ATTENDANCE_MANAGE");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async (): Promise<void> => {
if (!deleteId || !onDelete) return;
setIsDeleting(true);
try {
await onDelete(deleteId);
setDeleteId(null);
} finally {
setIsDeleting(false);
}
};
if (records.length === 0) {
return (
<EmptyState
icon={Inbox}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
className="h-auto border-none shadow-none"
/>
);
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("list.colStudentName")}</TableHead>
<TableHead>{t("list.colClassName")}</TableHead>
<TableHead>{t("list.colDate")}</TableHead>
<TableHead>{t("list.colStatus")}</TableHead>
<TableHead>{t("list.colRemark")}</TableHead>
<TableHead>{t("list.colRecordedBy")}</TableHead>
<TableHead>{t("list.colUpdatedAt")}</TableHead>
{canManage ? <TableHead className="w-12"></TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatAttendanceDate(r.date)}
</TableCell>
<TableCell>
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(r.status)}`}
>
{formatAttendanceStatus(r.status)}
</span>
</TableCell>
<TableCell className="max-w-[200px] truncate text-muted-foreground">
{r.remark ?? "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{r.recordedBy}
</TableCell>
<TableCell className="text-muted-foreground">
{formatAttendanceDate(r.updatedAt)}
</TableCell>
{canManage ? (
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={t("list.colActions", { default: "Delete" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog
open={deleteId !== null}
onOpenChange={(open) => !open && setDeleteId(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("sheet.confirmDelete")}</DialogTitle>
<DialogDescription>{t("sheet.confirmDelete")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteId(null)}
disabled={isDeleting}
>
{t("actions.cancel", { default: "Cancel" })}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting
? `${t("actions.delete", { default: "Delete" })}...`
: t("actions.delete", { default: "Delete" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

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